@visa/cli 4.1.0-rc.190 → 4.1.0-rc.191
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/checkout-engine/adapters/generic.d.ts +2 -0
- package/dist/checkout-engine/adapters/generic.js +148 -42
- package/dist/checkout-engine/executor.js +1 -0
- package/dist/checkout-engine/types.d.ts +8 -0
- package/dist/cli.js +8 -8
- package/dist/mcp-server/index.js +6 -6
- package/native/bin/win32-x64/visa-keychain-win.exe +0 -0
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -43,9 +43,11 @@ export declare function contactNameShapes(contact: Contact, cardholderName?: str
|
|
|
43
43
|
};
|
|
44
44
|
export declare function fillContactFieldMap(page: Page, fields: FieldMap, contact: Contact, opts?: {
|
|
45
45
|
fillTimeoutMs?: number;
|
|
46
|
+
detect?: (page: Page) => Promise<DetectResult>;
|
|
46
47
|
}): Promise<FilledField[]>;
|
|
47
48
|
export declare function fillFieldMap(page: Page, fields: FieldMap, credential: CardCredential, contact: Contact, opts?: {
|
|
48
49
|
fillTimeoutMs?: number;
|
|
50
|
+
detect?: (page: Page) => Promise<DetectResult>;
|
|
49
51
|
}): Promise<FilledField[]>;
|
|
50
52
|
/**
|
|
51
53
|
* Re-detect and adopt fresh entries for every card field after the panel is
|
|
@@ -120,6 +120,90 @@ export function summarizeFillFailure(error) {
|
|
|
120
120
|
return error.split('\n')[0].slice(0, 120);
|
|
121
121
|
}
|
|
122
122
|
const DEFAULT_FILL_TIMEOUT_MS = 5000;
|
|
123
|
+
/**
|
|
124
|
+
* How many times one fill pass may re-detect after a locator goes stale.
|
|
125
|
+
*
|
|
126
|
+
* One re-mount usually invalidates a whole block of fields at once, so a single
|
|
127
|
+
* pass normally recovers all of them. The cap is here so a page that re-mounts
|
|
128
|
+
* on EVERY keystroke degrades to the old timeout behaviour instead of looping.
|
|
129
|
+
*/
|
|
130
|
+
const MAX_FILL_REDETECTS = 3;
|
|
131
|
+
/**
|
|
132
|
+
* True when a detected field's locator no longer resolves to anything.
|
|
133
|
+
*
|
|
134
|
+
* `data-ca-id` is stamped onto the live nodes during detection, so it is an
|
|
135
|
+
* identity that dies with the node. A merchant that re-mounts part of its form
|
|
136
|
+
* mid-fill (address autocomplete is the common one) replaces those nodes with
|
|
137
|
+
* fresh, unstamped ones — the old locator then matches NOTHING and the fill
|
|
138
|
+
* waits out its entire timeout on an element that can never appear.
|
|
139
|
+
*/
|
|
140
|
+
async function locatorIsMissing(page, entry) {
|
|
141
|
+
try {
|
|
142
|
+
return (await resolveLocator(page, entry).count()) === 0;
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
// A frame that went away, a detached context: let the fill run and report
|
|
146
|
+
// the real error rather than inventing a diagnosis here.
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Contact and address roles — plain inputs whose value can be read straight
|
|
152
|
+
* back. The credential roles are deliberately absent: they sit in PSP iframes
|
|
153
|
+
* that tokenize and reformat what they are given, so a readback there compares
|
|
154
|
+
* against a value the merchant never promised to keep, and they are not what an
|
|
155
|
+
* address re-mount disturbs anyway.
|
|
156
|
+
*/
|
|
157
|
+
const READBACK_ROLES = [
|
|
158
|
+
'name',
|
|
159
|
+
'nameFirst',
|
|
160
|
+
'nameLast',
|
|
161
|
+
'email',
|
|
162
|
+
'phone',
|
|
163
|
+
'addressLine1',
|
|
164
|
+
'addressLine2',
|
|
165
|
+
'city',
|
|
166
|
+
'state',
|
|
167
|
+
'postalCode',
|
|
168
|
+
'country',
|
|
169
|
+
];
|
|
170
|
+
/**
|
|
171
|
+
* Downgrade any field whose value did not survive to the end of the pass.
|
|
172
|
+
*
|
|
173
|
+
* Only runs when a re-mount was actually observed, and only for the roles
|
|
174
|
+
* above. A page that rebuilds its address block on EVERY edit erases the field
|
|
175
|
+
* we just filled when we fill the NEXT one, so a fill can report success over a
|
|
176
|
+
* box that is empty by the time anyone submits it. Recovering from a re-mount
|
|
177
|
+
* must not buy that recovery with a false claim: without this, the engine would
|
|
178
|
+
* hand the pre-submit gate a clean bill of health for an empty form — and the
|
|
179
|
+
* gate would let it through. Before the recovery existed these fields failed
|
|
180
|
+
* loudly instead, and a wrong "filled" is worse than an honest refusal.
|
|
181
|
+
*/
|
|
182
|
+
async function downgradeFieldsThatDidNotHold(page, fields, filled) {
|
|
183
|
+
for (const record of filled) {
|
|
184
|
+
if (!record.ok)
|
|
185
|
+
continue;
|
|
186
|
+
if (!READBACK_ROLES.includes(record.role))
|
|
187
|
+
continue;
|
|
188
|
+
const entry = fields[record.role];
|
|
189
|
+
if (!entry)
|
|
190
|
+
continue;
|
|
191
|
+
let held;
|
|
192
|
+
try {
|
|
193
|
+
const loc = resolveLocator(page, entry);
|
|
194
|
+
held = (await loc.count()) > 0 && (await loc.inputValue()).trim() !== '';
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
// Unreadable is not evidence of empty. Leave the record alone rather than
|
|
198
|
+
// failing a field that is probably fine.
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (held)
|
|
202
|
+
continue;
|
|
203
|
+
record.ok = false;
|
|
204
|
+
record.error = 'value did not survive a re-render of the form';
|
|
205
|
+
}
|
|
206
|
+
}
|
|
123
207
|
async function fillOne(page, role, entry, value, displayValue, fillTimeoutMs) {
|
|
124
208
|
const base = {
|
|
125
209
|
role,
|
|
@@ -159,23 +243,24 @@ export function contactNameShapes(contact, cardholderName) {
|
|
|
159
243
|
}
|
|
160
244
|
async function fillFields(page, fields, credential, contact, opts = {}) {
|
|
161
245
|
const fillTimeoutMs = opts.fillTimeoutMs ?? DEFAULT_FILL_TIMEOUT_MS;
|
|
246
|
+
const detect = opts.detect ?? detectFields;
|
|
162
247
|
const filled = [];
|
|
163
248
|
const { fullName, first, last } = contactNameShapes(contact, credential?.cardholderName);
|
|
164
249
|
// Order matters a little: contact/name before card is harmless, but we fill
|
|
165
250
|
// card fields explicitly per role so order is not load-bearing.
|
|
251
|
+
//
|
|
252
|
+
// A job records the ROLE it fills, never a captured entry: the entry is
|
|
253
|
+
// resolved from the live map at the moment the job runs, because a re-mount
|
|
254
|
+
// part-way through this pass can replace the element a role points at.
|
|
255
|
+
// `value` is derived from that same entry, since whether a role is a <select>
|
|
256
|
+
// (and which options it offers) is a property of the element we end up with.
|
|
166
257
|
const jobs = [];
|
|
167
|
-
const add = (role,
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
// panel or a not-yet-reached step). The executor reveals them and re-fills.
|
|
172
|
-
if (entry.visible === false)
|
|
258
|
+
const add = (role, detected, value, display) => {
|
|
259
|
+
// Gate on the role having been detected at all. Roles absent from the
|
|
260
|
+
// original map stay out of scope even if a later pass would find them.
|
|
261
|
+
if (!detected)
|
|
173
262
|
return;
|
|
174
|
-
jobs.push({
|
|
175
|
-
role,
|
|
176
|
-
entry,
|
|
177
|
-
run: async () => fillOne(page, role, entry, value(), display(), fillTimeoutMs),
|
|
178
|
-
});
|
|
263
|
+
jobs.push({ role, value, display });
|
|
179
264
|
};
|
|
180
265
|
if (credential) {
|
|
181
266
|
add('number', fields.number, () => credential.pan, () => maskPan(credential.pan));
|
|
@@ -190,30 +275,22 @@ async function fillFields(page, fields, credential, contact, opts = {}) {
|
|
|
190
275
|
// Expiry display values are always redacted: the expiry is part of the
|
|
191
276
|
// keyable credential (DPAN + expiry + DAVV) and never enters the log.
|
|
192
277
|
if (credential && fields.expCombined) {
|
|
193
|
-
|
|
194
|
-
const v = expCombinedValue(e, credential.expMonth, credential.expYear);
|
|
195
|
-
add('expCombined', e, () => v, () => maskExpiry());
|
|
278
|
+
add('expCombined', fields.expCombined, (e) => expCombinedValue(e, credential.expMonth, credential.expYear), () => maskExpiry());
|
|
196
279
|
}
|
|
197
280
|
if (credential && fields.expMonth) {
|
|
198
|
-
|
|
199
|
-
const value = e.tag === 'select'
|
|
281
|
+
add('expMonth', fields.expMonth, (e) => e.tag === 'select'
|
|
200
282
|
? (monthOptionValue(e.options ?? [], credential.expMonth) ?? pad2(credential.expMonth))
|
|
201
|
-
: pad2(credential.expMonth);
|
|
202
|
-
add('expMonth', e, () => value, () => maskExpiry());
|
|
283
|
+
: pad2(credential.expMonth), () => maskExpiry());
|
|
203
284
|
}
|
|
204
285
|
if (credential && fields.expYear) {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
}
|
|
213
|
-
else {
|
|
214
|
-
value = String(credential.expYear);
|
|
215
|
-
}
|
|
216
|
-
add('expYear', e, () => value, () => maskExpiry());
|
|
286
|
+
add('expYear', fields.expYear, (e) => {
|
|
287
|
+
if (e.tag === 'select') {
|
|
288
|
+
return yearOptionValue(e.options ?? [], credential.expYear) ?? String(credential.expYear);
|
|
289
|
+
}
|
|
290
|
+
if ((e.maxlength ?? 0) === 2)
|
|
291
|
+
return pad2(credential.expYear % 100);
|
|
292
|
+
return String(credential.expYear);
|
|
293
|
+
}, () => maskExpiry());
|
|
217
294
|
}
|
|
218
295
|
// Contact / shipping. Display values are always redacted: these are PII and
|
|
219
296
|
// the evidence log is built to be persistable.
|
|
@@ -230,23 +307,52 @@ async function fillFields(page, fields, credential, contact, opts = {}) {
|
|
|
230
307
|
if (contact.postalCode)
|
|
231
308
|
add('postalCode', fields.postalCode, () => contact.postalCode, () => redactContact('postalCode', contact.postalCode));
|
|
232
309
|
if (fields.state && contact.state) {
|
|
233
|
-
const e = fields.state;
|
|
234
310
|
const wanted = contact.state;
|
|
235
|
-
|
|
236
|
-
add('state', e, () => value, () => redactContact('state', value));
|
|
311
|
+
add('state', fields.state, (e) => (e.tag === 'select' ? (matchOption(e.options ?? [], [wanted]) ?? wanted) : wanted), (value) => redactContact('state', value));
|
|
237
312
|
}
|
|
238
313
|
if (fields.country && contact.country) {
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
? (matchOption(e.options ?? [], [contact.country]) ?? contact.country)
|
|
242
|
-
: contact.country;
|
|
243
|
-
add('country', e, () => value, () => redactContact('country', value));
|
|
314
|
+
const wanted = contact.country;
|
|
315
|
+
add('country', fields.country, (e) => (e.tag === 'select' ? (matchOption(e.options ?? [], [wanted]) ?? wanted) : wanted), (value) => redactContact('country', value));
|
|
244
316
|
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
317
|
+
// The map is re-read per job, and a re-detect REPLACES it wholesale rather
|
|
318
|
+
// than patching the one entry that went stale: a detection pass re-stamps
|
|
319
|
+
// every element from scratch, so an id handed out by the previous pass can
|
|
320
|
+
// afterwards name a different element. Adopting the fresh map for every
|
|
321
|
+
// remaining job is what keeps a recovery from filling the wrong box.
|
|
322
|
+
let current = fields;
|
|
323
|
+
let redetects = 0;
|
|
324
|
+
for (const job of jobs) {
|
|
325
|
+
let entry = current[job.role];
|
|
326
|
+
// Skip fields that are not currently visible (e.g. a collapsed accordion
|
|
327
|
+
// panel or a not-yet-reached step). The executor reveals them and re-fills.
|
|
328
|
+
if (!entry || entry.visible === false)
|
|
329
|
+
continue;
|
|
330
|
+
let relocated = false;
|
|
331
|
+
if (redetects < MAX_FILL_REDETECTS && (await locatorIsMissing(page, entry))) {
|
|
332
|
+
redetects++;
|
|
333
|
+
const fresh = await detect(page).then((r) => r.fields, () => null);
|
|
334
|
+
if (fresh) {
|
|
335
|
+
current = fresh;
|
|
336
|
+
const next = current[job.role];
|
|
337
|
+
if (next && next.visible !== false) {
|
|
338
|
+
entry = next;
|
|
339
|
+
relocated = true;
|
|
340
|
+
}
|
|
341
|
+
// If the role is gone from the fresh map, deliberately keep the stale
|
|
342
|
+
// entry and let the fill fail on it. Skipping would leave NO record of
|
|
343
|
+
// the field, and the pre-submit gate reads "never attempted" as
|
|
344
|
+
// "nothing to worry about" — which is how an incomplete form gets
|
|
345
|
+
// submitted. A recorded failure refuses; a silent skip does not.
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
const value = job.value(entry);
|
|
349
|
+
const result = await fillOne(page, job.role, entry, value, job.display(value), fillTimeoutMs);
|
|
350
|
+
filled.push(relocated ? { ...result, relocated: true } : result);
|
|
249
351
|
}
|
|
352
|
+
// Only when a re-mount was actually seen: everywhere else the page never
|
|
353
|
+
// moved under us and the readback would be pure cost.
|
|
354
|
+
if (redetects > 0)
|
|
355
|
+
await downgradeFieldsThatDidNotHold(page, current, filled);
|
|
250
356
|
return filled;
|
|
251
357
|
}
|
|
252
358
|
// Credential-free contact prefill for merchants that must calculate shipping,
|
|
@@ -1095,6 +1095,7 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
1095
1095
|
value: f.value,
|
|
1096
1096
|
ok: f.ok,
|
|
1097
1097
|
error: f.error,
|
|
1098
|
+
...(f.relocated ? { relocated: true } : {}),
|
|
1098
1099
|
});
|
|
1099
1100
|
}
|
|
1100
1101
|
const numberOk = fill.filled.some((f) => f.role === 'number' && f.ok);
|
|
@@ -23,6 +23,14 @@ export type FilledField = {
|
|
|
23
23
|
value: string;
|
|
24
24
|
ok: boolean;
|
|
25
25
|
error?: string;
|
|
26
|
+
/**
|
|
27
|
+
* The detected node was gone by the time this field's turn came (the merchant
|
|
28
|
+
* re-mounted that part of the form mid-fill) and a fresh detection pass was
|
|
29
|
+
* used to find it again. Worth surfacing: a merchant that re-mounts is a
|
|
30
|
+
* merchant whose fills are order-sensitive, which is the first thing to know
|
|
31
|
+
* when one of them later refuses.
|
|
32
|
+
*/
|
|
33
|
+
relocated?: boolean;
|
|
26
34
|
};
|
|
27
35
|
export type FillResult = {
|
|
28
36
|
ok: boolean;
|