@visa/cli 4.1.0-rc.3 → 4.1.0-rc.31

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 +132 -242
  2. package/dist/checkout-engine/adapters/generic.d.ts +19 -0
  3. package/dist/checkout-engine/adapters/generic.js +201 -0
  4. package/dist/checkout-engine/adapters/index.d.ts +7 -0
  5. package/dist/checkout-engine/adapters/index.js +17 -0
  6. package/dist/checkout-engine/adapters/stripe-like.d.ts +10 -0
  7. package/dist/checkout-engine/adapters/stripe-like.js +21 -0
  8. package/dist/checkout-engine/browser-launch.d.ts +46 -0
  9. package/dist/checkout-engine/browser-launch.js +81 -0
  10. package/dist/checkout-engine/ceremony.d.ts +64 -0
  11. package/dist/checkout-engine/ceremony.js +261 -0
  12. package/dist/checkout-engine/cli-engine.d.ts +208 -0
  13. package/dist/checkout-engine/cli-engine.js +584 -0
  14. package/dist/checkout-engine/detect.d.ts +61 -0
  15. package/dist/checkout-engine/detect.js +392 -0
  16. package/dist/checkout-engine/evidence.d.ts +25 -0
  17. package/dist/checkout-engine/evidence.js +104 -0
  18. package/dist/checkout-engine/executor.d.ts +174 -0
  19. package/dist/checkout-engine/executor.js +1306 -0
  20. package/dist/checkout-engine/hosted-approval.d.ts +135 -0
  21. package/dist/checkout-engine/hosted-approval.js +311 -0
  22. package/dist/checkout-engine/index.d.ts +6 -0
  23. package/dist/checkout-engine/index.js +8 -0
  24. package/dist/checkout-engine/inline-target.d.ts +13 -0
  25. package/dist/checkout-engine/inline-target.js +37 -0
  26. package/dist/checkout-engine/instrument.d.ts +55 -0
  27. package/dist/checkout-engine/instrument.js +87 -0
  28. package/dist/checkout-engine/live-fill-approval.d.ts +43 -0
  29. package/dist/checkout-engine/live-fill-approval.js +90 -0
  30. package/dist/checkout-engine/mandate/card-mandate.d.ts +117 -0
  31. package/dist/checkout-engine/mandate/card-mandate.js +221 -0
  32. package/dist/checkout-engine/mandate/mandate-ledger.d.ts +135 -0
  33. package/dist/checkout-engine/mandate/mandate-ledger.js +318 -0
  34. package/dist/checkout-engine/mandate.d.ts +25 -0
  35. package/dist/checkout-engine/mandate.js +100 -0
  36. package/dist/checkout-engine/outcome.d.ts +30 -0
  37. package/dist/checkout-engine/outcome.js +225 -0
  38. package/dist/checkout-engine/owner-only-file.d.ts +19 -0
  39. package/dist/checkout-engine/owner-only-file.js +41 -0
  40. package/dist/checkout-engine/package.json +3 -0
  41. package/dist/checkout-engine/pay-args.d.ts +14 -0
  42. package/dist/checkout-engine/pay-args.js +44 -0
  43. package/dist/checkout-engine/pay.d.ts +1 -0
  44. package/dist/checkout-engine/pay.js +13 -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/run-live-fill.d.ts +1 -0
  50. package/dist/checkout-engine/run-live-fill.js +493 -0
  51. package/dist/checkout-engine/types.d.ts +39 -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 +178 -0
  57. package/dist/checkout-engine/vgs-live-instrument.d.ts +168 -0
  58. package/dist/checkout-engine/vgs-live-instrument.js +289 -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 +327 -375
  62. package/dist/mcp-server/index.js +253 -163
  63. package/dist/skills/pair-visa-agent/RUNTIMES.md +79 -0
  64. package/dist/skills/pair-visa-agent/SKILL.md +403 -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 +9 -5
  70. package/server.json +3 -3
@@ -0,0 +1,1306 @@
1
+ // Executor. The explicit confirmation path is split into two phases:
2
+ // prepareCheckout(): navigate -> stabilize -> mandate gate -> resolve the
3
+ // review facts. No credential is requested or filled in this phase.
4
+ // submitApprovedCheckout(): verify the approval is bound to that review ->
5
+ // revalidate -> mint credential -> fill -> revalidate -> submit.
6
+ //
7
+ // runCheckout() remains the one-shot, auto-approved compatibility wrapper.
8
+ //
9
+ // Two-phase mandate gate:
10
+ // - The PRE-FILL gate (structure, expiry, merchant host, asserted currency)
11
+ // runs before instrument.getCredential(). No credential is minted and no
12
+ // field is filled on a page the mandate does not cover.
13
+ // - The PRE-SUBMIT gate re-runs the full check with the resolved amount and
14
+ // currency. No submit ever happens without it passing. Dry-run never
15
+ // clicks submit.
16
+ import { randomUUID } from 'node:crypto';
17
+ import { mkdir } from 'node:fs/promises';
18
+ import { join } from 'node:path';
19
+ import { detectFields } from './detect.js';
20
+ import { checkMandate, checkMandatePreFill } from './mandate.js';
21
+ import { EvidenceLog, maskOtp } from './evidence.js';
22
+ import { observeOutcome } from './outcome.js';
23
+ import { selectAdapter } from './adapters/index.js';
24
+ const SUBMIT_TEXT = /pay|place order|complete|buy|submit|checkout/i;
25
+ const REVEAL_TEXT = /continue|next|proceed|review|go to payment/i;
26
+ // Post-submit confirmed/declined/challenge signals live in outcome.ts
27
+ // (classifyOutcomePage).
28
+ async function waitForStableDom(page) {
29
+ await page.waitForLoadState('domcontentloaded').catch(() => { });
30
+ // networkidle is best-effort and bounded: a keep-alive socket or a slow PSP
31
+ // asset can keep it from ever firing, so we never block on it.
32
+ await page.waitForLoadState('networkidle', { timeout: 2000 }).catch(() => { });
33
+ }
34
+ async function settle(page) {
35
+ await page.waitForTimeout(200);
36
+ await page.waitForLoadState('networkidle', { timeout: 1500 }).catch(() => { });
37
+ }
38
+ // Convert a human decimal like "$50.00" to integer minor units without
39
+ // floats. Fail-closed on separator ambiguity: only layouts with exactly one
40
+ // reading are parsed; anything else returns null and the caller refuses. The
41
+ // dangerous direction is UNDER-reading (an EU "1.234,56" read as 1.23 lets an
42
+ // over-cap total pass the gate), so no layout is ever guessed.
43
+ // Only 2-decimal currencies are supported (see pageCurrency's ISO allowlist).
44
+ export function minorFromDecimal(text) {
45
+ const m = text.match(/\d[\d.,]*/);
46
+ if (!m)
47
+ return null;
48
+ const token = m[0].replace(/[.,]+$/, '');
49
+ // "1234" — plain integer major units.
50
+ if (/^\d+$/.test(token))
51
+ return Number.parseInt(token, 10) * 100;
52
+ // "1,234.56" — thousands groups of exactly 3 plus a 2-digit decimal.
53
+ if (/^\d{1,3}(,\d{3})+\.\d{2}$/.test(token)) {
54
+ const [whole, frac] = token.replace(/,/g, '').split('.');
55
+ return Number.parseInt(whole, 10) * 100 + Number.parseInt(frac, 10);
56
+ }
57
+ // "1.234,56" — the EU mirror: dot thousands, comma decimal.
58
+ if (/^\d{1,3}(\.\d{3})+,\d{2}$/.test(token)) {
59
+ const [whole, frac] = token.replace(/\./g, '').split(',');
60
+ return Number.parseInt(whole, 10) * 100 + Number.parseInt(frac, 10);
61
+ }
62
+ // "49.99" / "49.9" — dot decimal. A 3-digit dot group ("1.234") is EU
63
+ // thousands, not a decimal, so only 1-2 fraction digits qualify.
64
+ if (/^\d+\.\d{1,2}$/.test(token)) {
65
+ const [whole, frac] = token.split('.');
66
+ return Number.parseInt(whole, 10) * 100 + Number.parseInt(frac.padEnd(2, '0'), 10);
67
+ }
68
+ // "49,99" / "49,9" — comma decimal. Unambiguous: a thousands group is
69
+ // always exactly 3 digits, so a 1-2 digit comma tail can only be a decimal.
70
+ if (/^\d+,\d{1,2}$/.test(token)) {
71
+ const [whole, frac] = token.split(',');
72
+ return Number.parseInt(whole, 10) * 100 + Number.parseInt(frac.padEnd(2, '0'), 10);
73
+ }
74
+ // Everything else ("1,234" thousands-or-3-decimals, "1.2.3", ...) is
75
+ // ambiguous: refuse rather than guess.
76
+ return null;
77
+ }
78
+ // Currency stated by the page's total text, when unambiguous. "$" is shared
79
+ // by USD/CAD/AUD/... and never qualifies. The ISO allowlist is 2-decimal
80
+ // currencies only, matching minorFromDecimal's scaling.
81
+ const ISO_CURRENCIES = ['USD', 'EUR', 'GBP', 'CAD', 'AUD', 'CHF', 'NZD'];
82
+ export function pageCurrency(text) {
83
+ const iso = text.match(/\b([A-Z]{3})\b/);
84
+ if (iso && ISO_CURRENCIES.includes(iso[1]))
85
+ return iso[1];
86
+ if (text.includes('€'))
87
+ return 'EUR';
88
+ if (text.includes('£'))
89
+ return 'GBP';
90
+ return null;
91
+ }
92
+ // Read the order total from the page. Prefers an explicit data-total-minor
93
+ // attribute (machine-readable), else parses a labelled total from the page.
94
+ // Either way the currency comes from the element's text, or stays null. A
95
+ // total that is present but ambiguous is 'unreadable' — the executor refuses
96
+ // rather than falling back to the caller amount, because the page is showing
97
+ // the user a number we cannot verify against the mandate.
98
+ async function readPageAmount(page) {
99
+ const explicitLoc = page.locator('[data-total-minor]').first();
100
+ if ((await explicitLoc.count().catch(() => 0)) > 0) {
101
+ const explicit = await explicitLoc.getAttribute('data-total-minor').catch(() => null);
102
+ if (explicit && /^\d+$/.test(explicit)) {
103
+ const text = (await explicitLoc.textContent().catch(() => null)) ?? '';
104
+ return {
105
+ kind: 'ok',
106
+ amountMinor: Number.parseInt(explicit, 10),
107
+ currency: pageCurrency(text),
108
+ source: 'page-attr',
109
+ };
110
+ }
111
+ }
112
+ const totalLoc = page.locator('#order-total, .order-total, [data-testid="order-total"]').first();
113
+ if ((await totalLoc.count().catch(() => 0)) > 0) {
114
+ const totalText = await totalLoc.textContent().catch(() => null);
115
+ if (totalText && /\d/.test(totalText)) {
116
+ const amountMinor = minorFromDecimal(totalText);
117
+ if (amountMinor == null)
118
+ return { kind: 'unreadable' };
119
+ return { kind: 'ok', amountMinor, currency: pageCurrency(totalText), source: 'page-text' };
120
+ }
121
+ }
122
+ return { kind: 'none' };
123
+ }
124
+ async function tryReveal(page, evidence, clicked) {
125
+ // 1) A payment-method radio for card/credit/debit (accordion layouts).
126
+ const radios = page.locator('input[type="radio"]');
127
+ const rn = await radios.count().catch(() => 0);
128
+ for (let i = 0; i < rn; i++) {
129
+ const r = radios.nth(i);
130
+ const value = ((await r.getAttribute('value').catch(() => '')) || '').toLowerCase();
131
+ const id = (await r.getAttribute('id').catch(() => '')) || '';
132
+ let labelText = '';
133
+ if (id) {
134
+ labelText = ((await page
135
+ .locator(`label[for="${id}"]`)
136
+ .first()
137
+ .textContent()
138
+ .catch(() => '')) || '').toLowerCase();
139
+ }
140
+ if (/card|credit|debit/.test(`${value} ${labelText}`)) {
141
+ const key = `radio:${i}`;
142
+ if (!clicked.has(key)) {
143
+ await r.check().catch(() => { });
144
+ clicked.add(key);
145
+ evidence.step('reveal', { action: 'select-card-payment-method', index: i });
146
+ return true;
147
+ }
148
+ }
149
+ }
150
+ // 2) A continue/next/proceed control (multi-step layouts).
151
+ const btn = page.getByRole('button', { name: REVEAL_TEXT }).first();
152
+ const hasBtn = (await btn.count().catch(() => 0)) > 0;
153
+ if (hasBtn) {
154
+ const label = ((await btn.textContent().catch(() => '')) || '').trim();
155
+ // Never reveal-click an effective submit control: a bare <button>Continue
156
+ // inside a <form> is an implicit type="submit" (findSubmit only matches
157
+ // explicit [type=submit] + SUBMIT_TEXT, so it is never fingerprinted as
158
+ // the submit target), and clicking it would POST the form — bypassing the
159
+ // pre-submit mandate gate and the dry-run never-submits contract. Unknown
160
+ // (evaluate failed) is treated as would-submit: fail closed, don't click.
161
+ const wouldSubmit = await btn
162
+ .evaluate((el) => {
163
+ const control = el;
164
+ const type = (control.type || '').toLowerCase();
165
+ return Boolean(control.form) && (type === 'submit' || type === '');
166
+ })
167
+ .catch(() => true);
168
+ if (wouldSubmit) {
169
+ evidence.step('reveal', { action: 'skip-submit-control', label });
170
+ return false;
171
+ }
172
+ const key = `reveal-btn:${label}`;
173
+ if (!clicked.has(key)) {
174
+ await btn.click().catch(() => { });
175
+ clicked.add(key);
176
+ evidence.step('reveal', { action: 'advance-step', label });
177
+ return true;
178
+ }
179
+ }
180
+ return false;
181
+ }
182
+ async function fingerprintSubmitTarget(locator, kind, fallbackLabel) {
183
+ return locator.evaluate((element, { targetKind, targetFallbackLabel }) => {
184
+ const control = element;
185
+ const form = control.form ?? null;
186
+ const label = (control.textContent ?? '').trim() ||
187
+ (control.value ?? '').trim() ||
188
+ (control.getAttribute('aria-label') ?? '').trim() ||
189
+ targetFallbackLabel;
190
+ const formAction = control.getAttribute('formaction') || form?.action || null;
191
+ const formMethod = control.getAttribute('formmethod') || form?.method || null;
192
+ const formTarget = control.getAttribute('formtarget') || form?.target || null;
193
+ return {
194
+ kind: targetKind,
195
+ label,
196
+ elementTag: control.tagName.toLowerCase(),
197
+ elementId: control.id || null,
198
+ elementName: control.getAttribute('name') || null,
199
+ elementType: control.getAttribute('type')?.toLowerCase() || null,
200
+ formAction,
201
+ formMethod: formMethod?.toUpperCase() || null,
202
+ formTarget: formTarget || null,
203
+ };
204
+ }, { targetKind: kind, targetFallbackLabel: fallbackLabel });
205
+ }
206
+ async function findSubmit(page) {
207
+ const submitBtn = page.locator('button[type="submit"], input[type="submit"]').first();
208
+ if ((await submitBtn.count().catch(() => 0)) > 0) {
209
+ const label = ((await submitBtn.textContent().catch(() => '')) || '').trim() ||
210
+ (await submitBtn.getAttribute('value').catch(() => '')) ||
211
+ 'submit';
212
+ return {
213
+ desc: `submit button ("${label}")`,
214
+ fingerprint: await fingerprintSubmitTarget(submitBtn, 'submit-control', 'submit'),
215
+ click: () => submitBtn.click(),
216
+ };
217
+ }
218
+ const byText = page.getByRole('button', { name: SUBMIT_TEXT }).first();
219
+ if ((await byText.count().catch(() => 0)) > 0) {
220
+ const label = ((await byText.textContent().catch(() => '')) || '').trim();
221
+ return {
222
+ desc: `text button ("${label}")`,
223
+ fingerprint: await fingerprintSubmitTarget(byText, 'text-button', 'button'),
224
+ click: () => byText.click(),
225
+ };
226
+ }
227
+ return null;
228
+ }
229
+ async function snapshotSummary(page) {
230
+ const info = await page
231
+ .evaluate(() => {
232
+ const heading = document.querySelector('h1, h2, [role="heading"]');
233
+ const body = (document.body?.innerText || '').replace(/\s+/g, ' ').trim().slice(0, 240);
234
+ return { title: document.title, heading: heading?.textContent?.trim() || '', body };
235
+ })
236
+ .catch(() => ({ title: '', heading: '', body: '' }));
237
+ return `url=${page.url()} title="${info.title}" heading="${info.heading}" body="${info.body}"`;
238
+ }
239
+ async function readConfirmationRef(page) {
240
+ const ref = await page
241
+ .locator('#order-ref, [data-order-ref]')
242
+ .first()
243
+ .textContent()
244
+ .catch(() => null);
245
+ if (ref && ref.trim())
246
+ return ref.trim();
247
+ const body = (await page.textContent('body').catch(() => '')) || '';
248
+ // "Order #…" is the classic phrasing; Shopify-style thank-you pages say
249
+ // "Confirmation #…" instead.
250
+ const m = body.match(/(?:order|confirmation)\s*#\s*([A-Za-z0-9-]+)/i);
251
+ return m ? m[1] : undefined;
252
+ }
253
+ export const DEFAULT_PREPARED_CHECKOUT_TTL_MS = 5 * 60 * 1000;
254
+ export const DEFAULT_PREPARED_CHECKOUT_CLEANUP_RETRY_MS = 5_000;
255
+ // M0 keeps live Playwright handles in one process, but keys them by the public
256
+ // review ID so the prepare and approval calls do not depend on object identity.
257
+ // The interface is injectable; a control-plane implementation can replace this
258
+ // store without changing submit/cancel call signatures.
259
+ export class InMemoryPreparedCheckoutStore {
260
+ entries = new Map();
261
+ cleanupPending = new Map();
262
+ ttlMs;
263
+ cleanupRetryMs;
264
+ now;
265
+ reaper = null;
266
+ constructor(options = {}) {
267
+ this.ttlMs = options.ttlMs ?? DEFAULT_PREPARED_CHECKOUT_TTL_MS;
268
+ this.cleanupRetryMs = options.cleanupRetryMs ?? DEFAULT_PREPARED_CHECKOUT_CLEANUP_RETRY_MS;
269
+ this.now = options.now ?? Date.now;
270
+ if (!Number.isFinite(this.ttlMs) || this.ttlMs <= 0) {
271
+ throw new Error('prepared checkout ttlMs must be a positive finite number');
272
+ }
273
+ if (!Number.isFinite(this.cleanupRetryMs) || this.cleanupRetryMs <= 0) {
274
+ throw new Error('prepared checkout cleanupRetryMs must be a positive finite number');
275
+ }
276
+ }
277
+ put(reviewId, state) {
278
+ if (reviewId !== state.checkout.review.id) {
279
+ throw new Error('prepared checkout store key must match its review ID');
280
+ }
281
+ if (this.entries.has(reviewId) || this.cleanupPending.has(reviewId)) {
282
+ throw new Error(`prepared checkout already exists for review ${reviewId}`);
283
+ }
284
+ this.entries.set(reviewId, { state, expiresAtMs: this.now() + this.ttlMs });
285
+ this.scheduleReaper();
286
+ }
287
+ take(reviewId) {
288
+ const entry = this.entries.get(reviewId);
289
+ if (!entry)
290
+ return undefined;
291
+ this.entries.delete(reviewId);
292
+ this.scheduleReaper();
293
+ if (entry.expiresAtMs <= this.now()) {
294
+ this.queueCleanup(reviewId, entry.state, 'prepared checkout expired before it was consumed', this.now());
295
+ void this.reapExpired();
296
+ return undefined;
297
+ }
298
+ return entry.state;
299
+ }
300
+ // Intended for diagnostics and deterministic tests. Production submission
301
+ // still consumes through take(), preserving single-use behavior.
302
+ peek(reviewId) {
303
+ return this.entries.get(reviewId)?.state;
304
+ }
305
+ pendingCleanupReviewIds() {
306
+ return [...this.cleanupPending.keys()];
307
+ }
308
+ async reapExpired(nowMs = this.now()) {
309
+ let expiredCount = 0;
310
+ for (const [reviewId, entry] of this.entries) {
311
+ if (entry.expiresAtMs > nowMs)
312
+ continue;
313
+ this.entries.delete(reviewId);
314
+ this.queueCleanup(reviewId, entry.state, 'prepared checkout expired before approval or cancellation', nowMs);
315
+ expiredCount += 1;
316
+ }
317
+ const due = [...this.cleanupPending.entries()].filter(([, entry]) => entry.retryAtMs <= nowMs);
318
+ await Promise.all(due.map(async ([reviewId, entry]) => {
319
+ // Prevent a concurrent reap from starting a second close attempt.
320
+ entry.retryAtMs = Number.POSITIVE_INFINITY;
321
+ const recordApproval = !entry.approvalRecorded;
322
+ entry.approvalRecorded = true;
323
+ const closed = await this.closeState(entry.state, entry.reason, recordApproval);
324
+ if (closed) {
325
+ this.cleanupPending.delete(reviewId);
326
+ }
327
+ else if (this.cleanupPending.get(reviewId) === entry) {
328
+ entry.retryAtMs = nowMs + this.cleanupRetryMs;
329
+ }
330
+ }));
331
+ this.scheduleReaper();
332
+ return expiredCount;
333
+ }
334
+ async dispose() {
335
+ if (this.reaper)
336
+ clearTimeout(this.reaper);
337
+ this.reaper = null;
338
+ const states = [
339
+ ...[...this.entries.values()].map((entry) => ({
340
+ state: entry.state,
341
+ reason: 'prepared checkout store disposed',
342
+ })),
343
+ ...[...this.cleanupPending.values()].map((entry) => ({
344
+ state: entry.state,
345
+ reason: entry.reason,
346
+ })),
347
+ ];
348
+ this.entries.clear();
349
+ this.cleanupPending.clear();
350
+ await Promise.all(states.map(({ state, reason }) => this.closeState(state, reason, true)));
351
+ }
352
+ queueCleanup(reviewId, state, reason, retryAtMs) {
353
+ if (this.cleanupPending.has(reviewId))
354
+ return;
355
+ this.cleanupPending.set(reviewId, {
356
+ state,
357
+ reason,
358
+ retryAtMs,
359
+ approvalRecorded: false,
360
+ });
361
+ }
362
+ scheduleReaper() {
363
+ if (this.reaper)
364
+ clearTimeout(this.reaper);
365
+ this.reaper = null;
366
+ let nextExpiry = Number.POSITIVE_INFINITY;
367
+ for (const entry of this.entries.values()) {
368
+ nextExpiry = Math.min(nextExpiry, entry.expiresAtMs);
369
+ }
370
+ for (const entry of this.cleanupPending.values()) {
371
+ nextExpiry = Math.min(nextExpiry, entry.retryAtMs);
372
+ }
373
+ if (!Number.isFinite(nextExpiry))
374
+ return;
375
+ const delay = Math.max(0, Math.min(nextExpiry - this.now(), 2_147_483_647));
376
+ this.reaper = setTimeout(() => {
377
+ this.reaper = null;
378
+ void this.reapExpired();
379
+ }, delay);
380
+ this.reaper.unref();
381
+ }
382
+ async closeState(state, reason, recordApproval) {
383
+ if (recordApproval) {
384
+ state.evidence.step('approval', {
385
+ approved: false,
386
+ reviewId: state.checkout.review.id,
387
+ reason,
388
+ });
389
+ }
390
+ try {
391
+ await state.context.close();
392
+ return true;
393
+ }
394
+ catch (error) {
395
+ state.evidence.step('note', {
396
+ phase: 'prepared-session-cleanup',
397
+ reviewId: state.checkout.review.id,
398
+ error: error instanceof Error ? error.message : String(error),
399
+ });
400
+ return false;
401
+ }
402
+ }
403
+ }
404
+ const defaultPreparedCheckoutStore = new InMemoryPreparedCheckoutStore();
405
+ function unknownPreparedCheckoutResult(reviewId) {
406
+ const evidence = new EvidenceLog();
407
+ const detail = 'prepared checkout is unknown, expired, or already consumed';
408
+ evidence.step('approval', { approved: false, reviewId, reason: detail });
409
+ return makeResult('failed', {}, evidence, [], detail);
410
+ }
411
+ function makeResult(outcome, fields, evidence, requiresAdapter, detail, confirmationRef) {
412
+ const steps = evidence.getSteps();
413
+ const approved = steps.find((step) => step.type === 'approval' && step.data.approved === true);
414
+ const minted = steps.find((step) => step.type === 'credential-minted');
415
+ const completed = steps.find((step) => step.type === 'fill-complete');
416
+ const filledRoles = new Set(steps
417
+ .filter((step) => step.type === 'field-fill' && step.data.ok === true)
418
+ .map((step) => String(step.data.role)));
419
+ const fullyFilled = filledRoles.has('number') &&
420
+ filledRoles.has('cvc') &&
421
+ (filledRoles.has('expCombined') || (filledRoles.has('expMonth') && filledRoles.has('expYear')));
422
+ const credentialLifecycle = !minted
423
+ ? 'not-requested'
424
+ : fullyFilled
425
+ ? 'fully-filled'
426
+ : filledRoles.has('number') || filledRoles.has('cvc')
427
+ ? 'partially-exposed'
428
+ : 'minted-not-exposed';
429
+ return {
430
+ outcome,
431
+ fields,
432
+ evidence,
433
+ requiresAdapter: [...requiresAdapter],
434
+ credentialLifecycle,
435
+ credentialTiming: {
436
+ ...(approved ? { approvedAt: approved.ts } : {}),
437
+ ...(minted ? { credentialMintedAt: minted.ts } : {}),
438
+ ...(typeof minted?.data.credentialExpiresAt === 'string'
439
+ ? { credentialExpiresAt: minted.data.credentialExpiresAt }
440
+ : {}),
441
+ ...(completed ? { fillCompletedAt: completed.ts } : {}),
442
+ },
443
+ ...(detail ? { detail } : {}),
444
+ ...(confirmationRef ? { confirmationRef } : {}),
445
+ };
446
+ }
447
+ async function readTransactionFacts(page, opts) {
448
+ const pageAmount = await readPageAmount(page);
449
+ const amountMinor = pageAmount.kind === 'ok'
450
+ ? pageAmount.amountMinor
451
+ : pageAmount.kind === 'none'
452
+ ? (opts.amountMinor ?? null)
453
+ : null;
454
+ // A page-derived amount gates in the currency the page states or the
455
+ // caller asserts — never the mandate's by default. A caller-supplied
456
+ // amount is the caller's (amount, currency) pair, defaulting to the
457
+ // mandate currency as documented on PrepareCheckoutOptions.
458
+ const currency = pageAmount.kind === 'ok'
459
+ ? (pageAmount.currency ?? opts.currency ?? null)
460
+ : opts.amountMinor != null
461
+ ? (opts.currency ?? opts.mandate.currency)
462
+ : null;
463
+ const source = pageAmount.kind === 'ok'
464
+ ? pageAmount.source
465
+ : pageAmount.kind === 'unreadable'
466
+ ? 'page-unreadable'
467
+ : opts.amountMinor != null
468
+ ? 'caller'
469
+ : 'unknown';
470
+ if (amountMinor == null) {
471
+ return {
472
+ ok: false,
473
+ amountMinor,
474
+ currency,
475
+ source,
476
+ reason: pageAmount.kind === 'unreadable'
477
+ ? 'page total is displayed but cannot be parsed unambiguously'
478
+ : 'transaction amount could not be determined',
479
+ detail: pageAmount.kind === 'unreadable'
480
+ ? 'transaction amount could not be determined (page total present but ambiguous, e.g. separator layout); refusing fail-closed'
481
+ : 'transaction amount could not be determined (no readable page total, no amountMinor provided); refusing fail-closed',
482
+ };
483
+ }
484
+ if (currency == null) {
485
+ return {
486
+ ok: false,
487
+ amountMinor,
488
+ currency,
489
+ source,
490
+ reason: 'transaction currency could not be determined',
491
+ detail: 'transaction currency could not be determined (page total does not state one unambiguously, no currency asserted by the caller); refusing fail-closed',
492
+ };
493
+ }
494
+ return { ok: true, amountMinor, currency, source };
495
+ }
496
+ function recordTransactionFacts(evidence, phase, facts) {
497
+ evidence.step('note', {
498
+ phase,
499
+ amountSource: facts.source,
500
+ amountMinor: facts.amountMinor,
501
+ currency: facts.currency,
502
+ });
503
+ }
504
+ function reviewChangeReason(review, merchantHost, facts) {
505
+ if (merchantHost !== review.merchantHost) {
506
+ return `merchant changed after review: ${review.merchantHost} -> ${merchantHost}`;
507
+ }
508
+ if (facts.amountMinor !== review.amountMinor) {
509
+ return `amount changed after review: ${review.amountMinor} -> ${facts.amountMinor} (minor units)`;
510
+ }
511
+ if (facts.currency.toUpperCase() !== review.currency.toUpperCase()) {
512
+ return `currency changed after review: ${review.currency} -> ${facts.currency}`;
513
+ }
514
+ return null;
515
+ }
516
+ function submitTargetChangeReason(review, current) {
517
+ const reviewed = review.submitTargetFingerprint;
518
+ if (!reviewed && !current)
519
+ return null;
520
+ if (!reviewed && current) {
521
+ return `submit target appeared after review: ${current.desc}`;
522
+ }
523
+ if (reviewed && !current) {
524
+ return `submit target disappeared after review: ${review.submitTarget ?? 'reviewed control'}`;
525
+ }
526
+ const currentFingerprint = current?.fingerprint;
527
+ const fingerprintChanged = reviewed?.kind !== currentFingerprint?.kind ||
528
+ reviewed?.label !== currentFingerprint?.label ||
529
+ reviewed?.elementTag !== currentFingerprint?.elementTag ||
530
+ reviewed?.elementId !== currentFingerprint?.elementId ||
531
+ reviewed?.elementName !== currentFingerprint?.elementName ||
532
+ reviewed?.elementType !== currentFingerprint?.elementType ||
533
+ reviewed?.formAction !== currentFingerprint?.formAction ||
534
+ reviewed?.formMethod !== currentFingerprint?.formMethod ||
535
+ reviewed?.formTarget !== currentFingerprint?.formTarget;
536
+ if (fingerprintChanged) {
537
+ return `submit target changed after review: ${review.submitTarget ?? 'reviewed control'} -> ${current?.desc ?? 'none'}`;
538
+ }
539
+ return null;
540
+ }
541
+ // Belt-and-braces static mask: generic autocomplete roles + the concrete
542
+ // Stripe payment-link input names. This is NOT sufficient on its own — the fill
543
+ // is heuristic and can touch inputs (e.g. `<input id="card_num" maxlength="16">`
544
+ // detected by attr-heuristic, no autocomplete) that match none of these. The
545
+ // authoritative mask is built per-run from the detected field entries below.
546
+ const CREDENTIAL_MASK_SELECTOR = [
547
+ 'input[autocomplete="cc-number"]',
548
+ 'input[autocomplete="cc-csc"]',
549
+ 'input[autocomplete="cc-exp"]',
550
+ 'input[name="cardNumber"]',
551
+ 'input[name="cardCvc"]',
552
+ 'input[name="cardExpiry"]',
553
+ 'input[name="cardnumber"]',
554
+ 'input[name="cvc"]',
555
+ 'input[name="exp-date"]',
556
+ ].join(', ');
557
+ // Roles whose value is the credential and must NEVER reach disk.
558
+ const CREDENTIAL_ROLES = ['number', 'cvc', 'expCombined', 'expMonth', 'expYear'];
559
+ // Reconcile a challenge-hold's second observation with the original challenge
560
+ // verdict. If the hold expired still-unresolved (`unknown` + last-seen
561
+ // `action-required`), keep the ORIGINAL action-required — reporting `unknown`
562
+ // would throw away a state we understand precisely (the exact misreport the
563
+ // challenge hold exists to prevent). Any resolved verdict (confirmed/declined),
564
+ // or an `unknown` whose last-seen was `processing` (challenge gone, still
565
+ // settling), is the newer truth and wins.
566
+ export function reconcileHeldOutcome(original, held) {
567
+ if (held.status === 'unknown' && held.lastSeen === 'action-required')
568
+ return original;
569
+ return held;
570
+ }
571
+ // Plan the screenshot mask from what detection actually resolved — the same
572
+ // entries fillFieldMap fills — so a heuristically-detected card input is masked
573
+ // even though it matches no static selector. Fail closed: a credential field
574
+ // hosted inside a frame cannot be guaranteed reachable by the page-level mask,
575
+ // so the shot is skipped entirely rather than risk writing a PAN/CVC.
576
+ export function debugShotMaskPlan(fields) {
577
+ const credentialEntries = CREDENTIAL_ROLES.map((r) => fields[r]).filter((e) => Boolean(e));
578
+ if (credentialEntries.some((e) => e.frame)) {
579
+ return {
580
+ skipReason: 'credential field is frame-hosted — cannot guarantee mask coverage',
581
+ maskLocators: [],
582
+ maskFrames: [],
583
+ };
584
+ }
585
+ // Mask every detected field the agent could fill (credential AND contact —
586
+ // receipts are redaction-first, #5708), not only the credential roles.
587
+ const maskLocators = [];
588
+ const maskFrames = [];
589
+ for (const entry of Object.values(fields)) {
590
+ if (!entry)
591
+ continue;
592
+ if (entry.frame)
593
+ maskFrames.push({ frame: entry.frame, locator: entry.locator });
594
+ else
595
+ maskLocators.push(entry.locator);
596
+ }
597
+ return { skipReason: null, maskLocators, maskFrames };
598
+ }
599
+ // Best-effort debug screenshot — a capture failure must never affect the run.
600
+ async function captureDebugShot(page, dir, reviewId, label, evidence, fields) {
601
+ try {
602
+ const plan = debugShotMaskPlan(fields);
603
+ if (plan.skipReason) {
604
+ evidence.step('note', { debugShot: label, skipped: plan.skipReason });
605
+ return;
606
+ }
607
+ await mkdir(dir, { recursive: true });
608
+ const path = join(dir, `${reviewId}-${label}.png`);
609
+ const mask = [
610
+ page.locator(CREDENTIAL_MASK_SELECTOR),
611
+ ...plan.maskLocators.map((l) => page.locator(l)),
612
+ ...plan.maskFrames.map((f) => page.frameLocator(f.frame).locator(f.locator)),
613
+ ];
614
+ await page.screenshot({ path, mask, maskColor: '#000000' });
615
+ evidence.step('note', { debugShot: label, path });
616
+ }
617
+ catch {
618
+ // never break a checkout for a screenshot
619
+ }
620
+ }
621
+ // Stripe Link (the wallet that pops "Confirm it's you" for an enrolled email)
622
+ // decides to show its modal by calling the consumer-session lookup when the
623
+ // email is entered; the follow-up start_verification is what texts the OTP.
624
+ // Aborting the lookup suppresses the modal AND prevents the OTP from ever being
625
+ // sent — proven by live probe on donate.stripe.com. We ALWAYS suppress Link:
626
+ // this agent pays with the freshly minted VIC credential via the guest card
627
+ // fields and must never route to a Link-saved card. The matched hosts are
628
+ // Link-consumer endpoints ONLY — never the PaymentIntent confirm
629
+ // (/v1/payment_intents/…), so the charge path is untouched.
630
+ export function isStripeLinkConsumerRequest(url) {
631
+ return /(?:^|\/\/)([a-z0-9.-]*\.)?stripe\.com\/v1\/consumers\/sessions\/(?:lookup|start_verification)\b/i.test(url);
632
+ }
633
+ // Keyed by Page so the tracker installed at prepare time is reachable from the
634
+ // approved-submit leg without threading through the session store types.
635
+ const linkSuppressionByPage = new WeakMap();
636
+ // Exported for the link-quiet unit tests (a fake Page captures the route
637
+ // handler); production callers stay inside this module.
638
+ export async function suppressStripeLink(page, evidence) {
639
+ const state = { suppressed: 0, waiters: [] };
640
+ linkSuppressionByPage.set(page, state);
641
+ await page.route((u) => isStripeLinkConsumerRequest(typeof u === 'string' ? u : u.href), (route) => {
642
+ state.suppressed += 1;
643
+ if (state.suppressed === 1) {
644
+ // origin + pathname only — never the full URL. The lookup carries the
645
+ // email in the POST body today, but keep an operator email out of the
646
+ // evidence log even if Stripe moves a param to the query string (#5708).
647
+ const u = route.request().url();
648
+ let safe = u;
649
+ try {
650
+ const parsed = new URL(u);
651
+ safe = parsed.origin + parsed.pathname;
652
+ }
653
+ catch {
654
+ /* keep raw if unparseable */
655
+ }
656
+ evidence.step('note', { linkSuppressed: safe });
657
+ }
658
+ for (const wake of state.waiters.splice(0))
659
+ wake();
660
+ return route.abort();
661
+ });
662
+ }
663
+ /**
664
+ * Wait for the suppressed Stripe Link lookup to fire and settle BEFORE the
665
+ * submit click. Stripe debounces its consumer-session lookup ~300ms after the
666
+ * email input changes; our fill→click gap is single-digit ms, so the (aborted)
667
+ * lookup used to land INSIDE Stripe's in-flight submit chain and kill it
668
+ * silently — the click looked accepted but tokenization never ran and the page
669
+ * sat on the form until the outcome deadline (#5879: three identical live
670
+ * stalls at donate.stripe.com). Verified live A/B on that page: instant click →
671
+ * dead submit, no /v1/payment_methods; lookup settled first → tokenization and
672
+ * the confirm step both reached.
673
+ *
674
+ * If the lookup already fired, only the short settle applies (lets Stripe's
675
+ * abort handling unwind). If it never fires — non-Link page variants, no email
676
+ * field — the bound expires and the click proceeds as before.
677
+ */
678
+ export async function waitForLinkLookupQuiet(page, opts = {}) {
679
+ const boundMs = opts.boundMs ?? 1500;
680
+ const settleMs = opts.settleMs ?? 250;
681
+ const delay = opts.delay ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
682
+ const state = linkSuppressionByPage.get(page);
683
+ if (!state)
684
+ return { fired: false, waitedMs: 0 };
685
+ const started = Date.now();
686
+ if (state.suppressed === 0) {
687
+ await Promise.race([
688
+ new Promise((resolve) => state.waiters.push(resolve)),
689
+ delay(boundMs),
690
+ ]);
691
+ }
692
+ const fired = state.suppressed > 0;
693
+ if (fired)
694
+ await delay(settleMs);
695
+ return { fired, waitedMs: Date.now() - started };
696
+ }
697
+ // Payer-chosen amount inputs. Deliberately payment-link-specific (Stripe's
698
+ // customUnitAmount): on an ordinary checkout the total is merchant-controlled
699
+ // and typing into anything price-like must never happen.
700
+ const PAYER_AMOUNT_SELECTORS = ['input#customUnitAmount', 'input[name="customUnitAmount"]'];
701
+ async function fillPayerChosenAmount(page, amountMinor) {
702
+ // money-boundary: ALLOW_BOUNDARY — the merchant's payer-facing amount input requires a decimal string
703
+ const amount = (amountMinor / 100).toFixed(2);
704
+ for (const selector of PAYER_AMOUNT_SELECTORS) {
705
+ const loc = page.locator(selector).first();
706
+ if ((await loc.count().catch(() => 0)) === 0)
707
+ continue;
708
+ try {
709
+ await loc.click({ timeout: 2000 });
710
+ await loc.fill('');
711
+ await loc.pressSequentially(amount, { delay: 20 });
712
+ await loc.blur().catch(() => { });
713
+ // The page reformats ("5.00" → "$5.00"); accept any readback that
714
+ // parses to the same minor units.
715
+ const readback = (await loc.inputValue().catch(() => '')) || '';
716
+ const parsed = Number(readback.replace(/[^0-9.]/g, ''));
717
+ return { present: true, filled: Math.round(parsed * 100) === amountMinor, selector, readback };
718
+ }
719
+ catch {
720
+ return { present: true, filled: false, selector };
721
+ }
722
+ }
723
+ return { present: false, filled: false };
724
+ }
725
+ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore) {
726
+ // Snapshot the authorization inputs. The caller may retain and mutate its
727
+ // objects while a human is reviewing; resume must keep enforcing the exact
728
+ // mandate and fallback facts that produced the review.
729
+ const options = {
730
+ ...opts,
731
+ mandate: { ...opts.mandate },
732
+ };
733
+ const evidence = new EvidenceLog();
734
+ const context = await options.browser.newContext();
735
+ // Bound every action so a mis-detected or hidden element fails fast instead
736
+ // of stalling on Playwright's long default timeout.
737
+ context.setDefaultTimeout(6000);
738
+ context.setDefaultNavigationTimeout(15000);
739
+ const page = await context.newPage();
740
+ // Suppress Stripe Link before the first navigation so its consumer-session
741
+ // lookup never fires (no wallet modal, no OTP text). Guest-card fill — the
742
+ // path that carries the minted credential — is unaffected.
743
+ await suppressStripeLink(page, evidence);
744
+ const requiresAdapter = new Set();
745
+ let fields = {};
746
+ let keepOpen = false;
747
+ try {
748
+ evidence.step('navigation', { url: options.url });
749
+ await page.goto(options.url, { waitUntil: 'domcontentloaded' });
750
+ await waitForStableDom(page);
751
+ evidence.step('dom-stable', { url: page.url() });
752
+ const merchantHost = new URL(page.url()).hostname;
753
+ const preFill = checkMandatePreFill(options.mandate, {
754
+ merchantHost,
755
+ currency: options.currency ?? null,
756
+ });
757
+ evidence.step('mandate-verdict', { phase: 'pre-fill', ...preFill });
758
+ if (!preFill.ok) {
759
+ evidence.setSnapshotSummary(await snapshotSummary(page));
760
+ return {
761
+ status: 'finished',
762
+ result: makeResult('blocked-by-mandate', fields, evidence, requiresAdapter, preFill.reason),
763
+ };
764
+ }
765
+ // Payer-chosen amount (Stripe payment links): an empty customUnitAmount
766
+ // fails client-side validation at submit ("Enter an amount.") and no
767
+ // authorization is ever attempted. Fill the caller-approved amount BEFORE
768
+ // detection so review facts, mandate checks, and the submit all see the
769
+ // real total. Contains no credential — amount is caller-supplied config.
770
+ if (typeof options.amountMinor === 'number') {
771
+ const amountFill = await fillPayerChosenAmount(page, options.amountMinor);
772
+ if (amountFill.present) {
773
+ evidence.step('amount-fill', {
774
+ ok: amountFill.filled,
775
+ selector: amountFill.selector,
776
+ readback: amountFill.readback,
777
+ });
778
+ if (!amountFill.filled) {
779
+ evidence.setSnapshotSummary(await snapshotSummary(page));
780
+ return {
781
+ status: 'finished',
782
+ result: makeResult('failed', fields, evidence, requiresAdapter, `payer-chosen amount field (${amountFill.selector}) did not accept the approved amount`),
783
+ };
784
+ }
785
+ await waitForStableDom(page);
786
+ }
787
+ }
788
+ // Detection is read-only here. In particular, no adapter fill and no
789
+ // Instrument.getCredential() call can occur before an explicit approval.
790
+ const detected = await detectFields(page);
791
+ fields = detected.fields;
792
+ evidence.step('detect', {
793
+ phase: 'review',
794
+ attempt: 0,
795
+ roles: Object.keys(detected.fields),
796
+ psps: detected.psps,
797
+ });
798
+ for (const p of detected.psps) {
799
+ if (p.requiresAdapter) {
800
+ requiresAdapter.add(p.requiresAdapter);
801
+ evidence.step('psp-detected', { psp: p.psp, requiresAdapter: p.requiresAdapter });
802
+ }
803
+ }
804
+ const facts = await readTransactionFacts(page, options);
805
+ recordTransactionFacts(evidence, 'review', facts);
806
+ if (!facts.ok) {
807
+ evidence.step('mandate-verdict', { phase: 'review', ok: false, reason: facts.reason });
808
+ evidence.setSnapshotSummary(await snapshotSummary(page));
809
+ return {
810
+ status: 'finished',
811
+ result: makeResult('blocked-by-mandate', fields, evidence, requiresAdapter, facts.detail),
812
+ };
813
+ }
814
+ const verdict = checkMandate(options.mandate, {
815
+ merchantHost,
816
+ amountMinor: facts.amountMinor,
817
+ currency: facts.currency,
818
+ });
819
+ evidence.step('mandate-verdict', { phase: 'review', ...verdict });
820
+ if (!verdict.ok) {
821
+ evidence.setSnapshotSummary(await snapshotSummary(page));
822
+ return {
823
+ status: 'finished',
824
+ result: makeResult('blocked-by-mandate', fields, evidence, requiresAdapter, verdict.reason),
825
+ };
826
+ }
827
+ const submit = await findSubmit(page);
828
+ const review = Object.freeze({
829
+ id: randomUUID(),
830
+ url: page.url(),
831
+ merchantHost,
832
+ amountMinor: facts.amountMinor,
833
+ currency: facts.currency,
834
+ mandateMaxAmountMinor: options.mandate.maxAmountMinor,
835
+ mandateExpiresAt: options.mandate.expiresAt,
836
+ submitTarget: submit?.desc ?? null,
837
+ submitTargetFingerprint: submit ? Object.freeze({ ...submit.fingerprint }) : null,
838
+ detectedRoles: Object.freeze(Object.keys(fields)),
839
+ });
840
+ evidence.step('review', {
841
+ reviewId: review.id,
842
+ merchantHost: review.merchantHost,
843
+ amountMinor: review.amountMinor,
844
+ currency: review.currency,
845
+ submitTarget: review.submitTarget,
846
+ submitTargetFingerprint: review.submitTargetFingerprint,
847
+ detectedRoles: review.detectedRoles,
848
+ });
849
+ const checkout = Object.freeze({
850
+ review,
851
+ fields: Object.freeze({ ...fields }),
852
+ evidence,
853
+ requiresAdapter: Object.freeze([...requiresAdapter]),
854
+ });
855
+ store.put(review.id, {
856
+ checkout,
857
+ context,
858
+ page,
859
+ options,
860
+ evidence,
861
+ fields,
862
+ requiresAdapter,
863
+ });
864
+ if (options.debugShotsDir) {
865
+ await captureDebugShot(page, options.debugShotsDir, checkout.review.id, '1-review', evidence, fields);
866
+ }
867
+ keepOpen = true;
868
+ return { status: 'ready', checkout };
869
+ }
870
+ catch (err) {
871
+ const detail = err.message;
872
+ evidence.step('outcome', { outcome: 'failed', error: detail });
873
+ return {
874
+ status: 'finished',
875
+ result: makeResult('failed', fields, evidence, requiresAdapter, detail),
876
+ };
877
+ }
878
+ finally {
879
+ if (!keepOpen)
880
+ await context.close().catch(() => { });
881
+ }
882
+ }
883
+ export async function submitApprovedCheckout(reviewId, opts, store = defaultPreparedCheckoutStore) {
884
+ // A prepared session is single-use even when approval validation fails.
885
+ const state = store.take(reviewId);
886
+ if (!state)
887
+ return unknownPreparedCheckoutResult(reviewId);
888
+ const { checkout } = state;
889
+ const { context, page, options, evidence, requiresAdapter } = state;
890
+ try {
891
+ if (reviewId !== checkout.review.id ||
892
+ opts.approval?.approved !== true ||
893
+ opts.approval.reviewId !== checkout.review.id) {
894
+ evidence.step('approval', {
895
+ approved: false,
896
+ reason: 'approval does not match prepared review',
897
+ });
898
+ evidence.setSnapshotSummary(await snapshotSummary(page));
899
+ return makeResult('failed', state.fields, evidence, requiresAdapter, 'approval does not match prepared review');
900
+ }
901
+ // Revalidate the exact user-reviewed merchant, amount, and currency before
902
+ // the credential boundary. A changed checkout requires a fresh review.
903
+ await waitForStableDom(page);
904
+ const merchantHost = new URL(page.url()).hostname;
905
+ const preFill = checkMandatePreFill(options.mandate, {
906
+ merchantHost,
907
+ currency: options.currency ?? null,
908
+ });
909
+ evidence.step('mandate-verdict', { phase: 'approval', ...preFill });
910
+ if (!preFill.ok) {
911
+ evidence.step('approval', { approved: false, reviewId: checkout.review.id });
912
+ evidence.setSnapshotSummary(await snapshotSummary(page));
913
+ return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, preFill.reason);
914
+ }
915
+ const approvedFacts = await readTransactionFacts(page, options);
916
+ recordTransactionFacts(evidence, 'approval', approvedFacts);
917
+ if (!approvedFacts.ok) {
918
+ evidence.step('mandate-verdict', {
919
+ phase: 'approval',
920
+ ok: false,
921
+ reason: approvedFacts.reason,
922
+ });
923
+ evidence.step('approval', { approved: false, reviewId: checkout.review.id });
924
+ evidence.setSnapshotSummary(await snapshotSummary(page));
925
+ return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, approvedFacts.detail);
926
+ }
927
+ const approvalVerdict = checkMandate(options.mandate, {
928
+ merchantHost,
929
+ amountMinor: approvedFacts.amountMinor,
930
+ currency: approvedFacts.currency,
931
+ });
932
+ evidence.step('mandate-verdict', { phase: 'approval', ...approvalVerdict });
933
+ if (!approvalVerdict.ok) {
934
+ evidence.step('approval', { approved: false, reviewId: checkout.review.id });
935
+ evidence.setSnapshotSummary(await snapshotSummary(page));
936
+ return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, approvalVerdict.reason);
937
+ }
938
+ const changedAtApproval = reviewChangeReason(checkout.review, merchantHost, approvedFacts);
939
+ if (changedAtApproval) {
940
+ evidence.step('approval', {
941
+ approved: false,
942
+ reviewId: checkout.review.id,
943
+ reason: changedAtApproval,
944
+ });
945
+ evidence.setSnapshotSummary(await snapshotSummary(page));
946
+ return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, changedAtApproval);
947
+ }
948
+ const approvalSubmit = await findSubmit(page);
949
+ const submitChangedAtApproval = submitTargetChangeReason(checkout.review, approvalSubmit);
950
+ if (submitChangedAtApproval) {
951
+ evidence.step('approval', {
952
+ approved: false,
953
+ reviewId: checkout.review.id,
954
+ reason: submitChangedAtApproval,
955
+ });
956
+ evidence.setSnapshotSummary(await snapshotSummary(page));
957
+ return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, submitChangedAtApproval);
958
+ }
959
+ if (opts.mode === 'submit' && !approvalSubmit) {
960
+ const reason = 'no submit target was available for human review';
961
+ evidence.step('approval', {
962
+ approved: false,
963
+ reviewId: checkout.review.id,
964
+ reason,
965
+ });
966
+ evidence.setSnapshotSummary(await snapshotSummary(page));
967
+ return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, reason);
968
+ }
969
+ evidence.step('approval', { approved: true, reviewId: checkout.review.id });
970
+ const credential = await opts.instrument.getCredential({
971
+ merchantHost,
972
+ amountMinor: approvedFacts.amountMinor,
973
+ currency: approvedFacts.currency,
974
+ });
975
+ evidence.step('credential-minted', {
976
+ ...(credential.credentialExpiresAt
977
+ ? { credentialExpiresAt: credential.credentialExpiresAt }
978
+ : {}),
979
+ });
980
+ // Reveal + fill loop: fills whatever is present, then reveals the next
981
+ // surface (card radio / next step) until the card number is filled.
982
+ const clicked = new Set();
983
+ let adapterName = null;
984
+ for (let attempt = 0; attempt < 4; attempt++) {
985
+ // A reveal/continue action can navigate between attempts. Never expose
986
+ // the credential to a host other than the one the human reviewed.
987
+ const fillHost = new URL(page.url()).hostname;
988
+ if (fillHost !== checkout.review.merchantHost) {
989
+ const reason = `merchant changed after review: ${checkout.review.merchantHost} -> ${fillHost}`;
990
+ evidence.step('mandate-verdict', {
991
+ phase: 'approved-submit',
992
+ ok: false,
993
+ reason,
994
+ });
995
+ evidence.setSnapshotSummary(await snapshotSummary(page));
996
+ return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, reason);
997
+ }
998
+ const detected = await detectFields(page);
999
+ state.fields = detected.fields;
1000
+ evidence.step('detect', {
1001
+ phase: 'approved-submit',
1002
+ attempt,
1003
+ roles: Object.keys(detected.fields),
1004
+ psps: detected.psps,
1005
+ });
1006
+ for (const p of detected.psps) {
1007
+ if (p.requiresAdapter) {
1008
+ requiresAdapter.add(p.requiresAdapter);
1009
+ evidence.step('psp-detected', { psp: p.psp, requiresAdapter: p.requiresAdapter });
1010
+ }
1011
+ }
1012
+ const adapter = selectAdapter(detected);
1013
+ if (adapter.name !== adapterName) {
1014
+ adapterName = adapter.name;
1015
+ evidence.step('adapter-selected', { adapter: adapter.name });
1016
+ }
1017
+ const fill = await adapter.fill(page, detected.fields, credential, opts.contact);
1018
+ for (const f of fill.filled) {
1019
+ evidence.step('field-fill', {
1020
+ role: f.role,
1021
+ confidence: f.confidence,
1022
+ source: f.source,
1023
+ frame: f.frame,
1024
+ value: f.value,
1025
+ ok: f.ok,
1026
+ error: f.error,
1027
+ });
1028
+ }
1029
+ const numberOk = fill.filled.some((f) => f.role === 'number' && f.ok);
1030
+ if (numberOk)
1031
+ break;
1032
+ const revealed = await tryReveal(page, evidence, clicked);
1033
+ if (!revealed)
1034
+ break;
1035
+ await settle(page);
1036
+ }
1037
+ const successfulRoles = new Set(evidence
1038
+ .getSteps()
1039
+ .filter((step) => step.type === 'field-fill' && step.data.ok === true)
1040
+ .map((step) => String(step.data.role)));
1041
+ const missingCredentialRoles = [
1042
+ ...(successfulRoles.has('number') ? [] : ['number']),
1043
+ ...(successfulRoles.has('cvc') ? [] : ['cvc']),
1044
+ ...(successfulRoles.has('expCombined') ||
1045
+ (successfulRoles.has('expMonth') && successfulRoles.has('expYear'))
1046
+ ? []
1047
+ : ['expiry']),
1048
+ ];
1049
+ evidence.step('fill-complete', {
1050
+ ok: missingCredentialRoles.length === 0,
1051
+ missingCredentialRoles,
1052
+ });
1053
+ if (options.debugShotsDir) {
1054
+ await captureDebugShot(page, options.debugShotsDir, checkout.review.id, '2-filled', evidence, state.fields);
1055
+ }
1056
+ // Re-run the full gate after fill as well. Contact/shipping fields can
1057
+ // change the total; any drift from the approved review refuses before a
1058
+ // submit click and requires the caller to prepare a new review.
1059
+ const submitFacts = await readTransactionFacts(page, options);
1060
+ recordTransactionFacts(evidence, 'pre-submit', submitFacts);
1061
+ if (!submitFacts.ok) {
1062
+ evidence.step('mandate-verdict', {
1063
+ phase: 'pre-submit',
1064
+ ok: false,
1065
+ reason: submitFacts.reason,
1066
+ });
1067
+ evidence.setSnapshotSummary(await snapshotSummary(page));
1068
+ return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, submitFacts.detail);
1069
+ }
1070
+ const submitMerchantHost = new URL(page.url()).hostname;
1071
+ const verdict = checkMandate(options.mandate, {
1072
+ merchantHost: submitMerchantHost,
1073
+ amountMinor: submitFacts.amountMinor,
1074
+ currency: submitFacts.currency,
1075
+ });
1076
+ evidence.step('mandate-verdict', { phase: 'pre-submit', ...verdict });
1077
+ if (!verdict.ok) {
1078
+ evidence.setSnapshotSummary(await snapshotSummary(page));
1079
+ return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, verdict.reason);
1080
+ }
1081
+ const changedBeforeSubmit = reviewChangeReason(checkout.review, submitMerchantHost, submitFacts);
1082
+ if (changedBeforeSubmit) {
1083
+ evidence.step('mandate-verdict', {
1084
+ phase: 'pre-submit',
1085
+ ok: false,
1086
+ reason: changedBeforeSubmit,
1087
+ });
1088
+ evidence.setSnapshotSummary(await snapshotSummary(page));
1089
+ return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, changedBeforeSubmit);
1090
+ }
1091
+ const submit = await findSubmit(page);
1092
+ const submitChangedBeforeClick = submitTargetChangeReason(checkout.review, submit);
1093
+ if (submitChangedBeforeClick) {
1094
+ evidence.step('mandate-verdict', {
1095
+ phase: 'pre-submit',
1096
+ ok: false,
1097
+ reason: submitChangedBeforeClick,
1098
+ });
1099
+ evidence.setSnapshotSummary(await snapshotSummary(page));
1100
+ return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, submitChangedBeforeClick);
1101
+ }
1102
+ if (opts.mode === 'dry-run') {
1103
+ evidence.step('submit', { would: true, target: submit?.desc ?? 'none found' });
1104
+ evidence.setSnapshotSummary(await snapshotSummary(page));
1105
+ if (missingCredentialRoles.length > 0) {
1106
+ const adapterRequired = requiresAdapter.size > 0;
1107
+ return makeResult(adapterRequired ? 'adapter-required' : 'partial-fill', state.fields, evidence, requiresAdapter, adapterRequired
1108
+ ? `credential fields require adapter: ${[...requiresAdapter].join(', ')}; missing ${missingCredentialRoles.join(', ')}`
1109
+ : `credential fill incomplete: missing ${missingCredentialRoles.join(', ')}`);
1110
+ }
1111
+ return makeResult('filled-dry-run', state.fields, evidence, requiresAdapter, submit ? `would click ${submit.desc}` : 'no submit control detected');
1112
+ }
1113
+ if (missingCredentialRoles.length > 0) {
1114
+ evidence.setSnapshotSummary(await snapshotSummary(page));
1115
+ return makeResult('failed', state.fields, evidence, requiresAdapter, `credential fill incomplete: missing ${missingCredentialRoles.join(', ')}`);
1116
+ }
1117
+ if (!submit) {
1118
+ evidence.setSnapshotSummary(await snapshotSummary(page));
1119
+ return makeResult('failed', state.fields, evidence, requiresAdapter, 'no submit control detected');
1120
+ }
1121
+ // Capture the OTP poll watermark BEFORE the click. The click is what
1122
+ // triggers the merchant's verification email, so the watermark must precede
1123
+ // it — otherwise a fast OTP could arrive before we start looking (the 5s
1124
+ // waitForMessage skew is a second line of defence, but ordering matters).
1125
+ // This is just a timestamp — no PII — so it is safe to record.
1126
+ const otpWatermark = new Date().toISOString();
1127
+ evidence.step('note', { otpWatermarkCaptured: true });
1128
+ // The suppressed Link lookup must settle before the click or it breaks
1129
+ // Stripe's submit chain mid-flight (#5879) — see waitForLinkLookupQuiet.
1130
+ const linkQuiet = await waitForLinkLookupQuiet(page);
1131
+ evidence.step('note', { linkQuiet });
1132
+ evidence.step('submit', { clicked: true, target: submit.desc });
1133
+ await submit.click();
1134
+ await settle(page);
1135
+ // Observe (poll) rather than read once: declines often render in place
1136
+ // with no navigation, confirmations may arrive after several redirects or
1137
+ // a processing interstitial. The observer returns the moment either
1138
+ // definitive signal appears, or 'unknown' at the deadline.
1139
+ let observed = await observeOutcome(page, { deadlineMs: opts.outcomeDeadlineMs });
1140
+ if (observed.status === 'action-required' && opts.challengeHoldMs) {
1141
+ // Opt-in human-in-the-loop: keep the challenge on screen and resume
1142
+ // watching for the post-challenge outcome instead of ending the run
1143
+ // (which tears down the browser and kills a live challenge).
1144
+ evidence.step('challenge-hold', { signal: observed.signal, holdMs: opts.challengeHoldMs });
1145
+ opts.onChallengeHold?.(observed.signal);
1146
+ const held = await observeOutcome(page, {
1147
+ deadlineMs: opts.challengeHoldMs,
1148
+ holdThroughChallenge: true,
1149
+ });
1150
+ observed = reconcileHeldOutcome(observed, held);
1151
+ }
1152
+ // Agent-resolvable email OTP subroutine (SINGLE-USE). Fires only on a
1153
+ // 'verification-required' verdict (the merchant emailed a code to the
1154
+ // agent's own inbox) AND when a resolver is injected. Fills the code EXACTLY
1155
+ // ONCE, re-submits, and re-observes. Merchants invalidate a code on first
1156
+ // use, so a stale code is NEVER retried. On no resolver / timeout / missing
1157
+ // code field it falls through to the action-required (human) path below —
1158
+ // it never hangs and never re-fills credential material.
1159
+ if (observed.status === 'verification-required' && opts.resolveEmailOtp) {
1160
+ const otpDetect = await detectFields(page);
1161
+ const codeField = otpDetect.fields.oneTimeCode;
1162
+ if (!codeField) {
1163
+ evidence.step('note', { emailOtp: 'no one-time-code field detected' });
1164
+ }
1165
+ else {
1166
+ const resolution = await opts.resolveEmailOtp({
1167
+ after: otpWatermark,
1168
+ merchantHost: submitMerchantHost,
1169
+ });
1170
+ if (!resolution) {
1171
+ // Fail CLEAN: no code retrieved before the resolver's timeout.
1172
+ evidence.step('note', { emailOtp: 'not retrieved before timeout' });
1173
+ }
1174
+ else {
1175
+ // Fill once. maskOtp() ensures the code NEVER enters the evidence log
1176
+ // (receipt.ts's PAN backstop does not catch a 4-8 digit OTP). The
1177
+ // sender domain is a non-PII trust signal, safe to record.
1178
+ await page.locator(codeField.locator).fill(resolution.code);
1179
+ evidence.step('field-fill', {
1180
+ role: 'oneTimeCode',
1181
+ confidence: codeField.confidence,
1182
+ source: codeField.source,
1183
+ frame: codeField.frame,
1184
+ value: maskOtp(),
1185
+ ok: true,
1186
+ fromDomain: resolution.fromDomain,
1187
+ });
1188
+ const otpSubmit = await findSubmit(page);
1189
+ if (!otpSubmit) {
1190
+ evidence.step('note', { emailOtp: 'code filled but no submit control found' });
1191
+ }
1192
+ else {
1193
+ const otpLinkQuiet = await waitForLinkLookupQuiet(page);
1194
+ evidence.step('note', { linkQuiet: otpLinkQuiet, phase: 'post-otp' });
1195
+ evidence.step('submit', { clicked: true, target: otpSubmit.desc, phase: 'post-otp' });
1196
+ await otpSubmit.click();
1197
+ await settle(page);
1198
+ observed = await observeOutcome(page, { deadlineMs: opts.outcomeDeadlineMs });
1199
+ }
1200
+ }
1201
+ }
1202
+ }
1203
+ evidence.setSnapshotSummary(await snapshotSummary(page));
1204
+ if (options.debugShotsDir) {
1205
+ await captureDebugShot(page, options.debugShotsDir, checkout.review.id, '3-outcome', evidence, state.fields);
1206
+ }
1207
+ if (observed.status === 'declined') {
1208
+ evidence.step('outcome', {
1209
+ outcome: 'declined',
1210
+ signal: observed.signal,
1211
+ attempts: observed.attempts,
1212
+ elapsedMs: observed.elapsedMs,
1213
+ });
1214
+ return makeResult('declined', state.fields, evidence, requiresAdapter, `declined (${observed.signal})`);
1215
+ }
1216
+ if (observed.status === 'action-required') {
1217
+ // 3DS runs before authorization: a detected challenge means no charge
1218
+ // exists yet and none will until a human completes it. Definitive for
1219
+ // this run — the executor never attempts to interact with a challenge.
1220
+ evidence.step('outcome', {
1221
+ outcome: 'action-required',
1222
+ signal: observed.signal,
1223
+ attempts: observed.attempts,
1224
+ elapsedMs: observed.elapsedMs,
1225
+ });
1226
+ return makeResult('action-required', state.fields, evidence, requiresAdapter, `issuer verification required (${observed.signal}) — a human must complete the challenge; no charge exists until it is completed`);
1227
+ }
1228
+ if (observed.status === 'verification-required') {
1229
+ // Still needing an email code after the subroutine (no resolver injected,
1230
+ // the code never arrived, or no code field) — hand off to a human. Mapped
1231
+ // to the same action-required outcome; the code is single-use so we never
1232
+ // retry here.
1233
+ evidence.step('outcome', {
1234
+ outcome: 'action-required',
1235
+ signal: observed.signal,
1236
+ reason: 'email verification code required but not auto-resolved',
1237
+ attempts: observed.attempts,
1238
+ elapsedMs: observed.elapsedMs,
1239
+ });
1240
+ return makeResult('action-required', state.fields, evidence, requiresAdapter, `email verification required (${observed.signal}) — a human must enter the code sent to the inbox`);
1241
+ }
1242
+ if (observed.status === 'confirmed') {
1243
+ const confirmationRef = await readConfirmationRef(page);
1244
+ evidence.step('outcome', {
1245
+ outcome: 'confirmed',
1246
+ confirmationRef,
1247
+ signal: observed.signal,
1248
+ attempts: observed.attempts,
1249
+ elapsedMs: observed.elapsedMs,
1250
+ });
1251
+ return makeResult('confirmed', state.fields, evidence, requiresAdapter, undefined, confirmationRef);
1252
+ }
1253
+ evidence.step('outcome', {
1254
+ outcome: 'failed',
1255
+ reason: 'no confirmation or decline signal',
1256
+ lastSeen: observed.lastSeen,
1257
+ attempts: observed.attempts,
1258
+ elapsedMs: observed.elapsedMs,
1259
+ });
1260
+ return makeResult('failed', state.fields, evidence, requiresAdapter, observed.lastSeen === 'processing'
1261
+ ? 'submitted but outcome unknown (page still processing at deadline)'
1262
+ : 'submitted but outcome unknown');
1263
+ }
1264
+ catch (err) {
1265
+ const detail = err.message;
1266
+ evidence.step('outcome', { outcome: 'failed', error: detail });
1267
+ return makeResult('failed', state.fields, evidence, requiresAdapter, detail);
1268
+ }
1269
+ finally {
1270
+ await context.close().catch(() => { });
1271
+ }
1272
+ }
1273
+ export async function cancelPreparedCheckout(reviewId, detail = 'checkout cancelled before approval', store = defaultPreparedCheckoutStore) {
1274
+ const state = store.take(reviewId);
1275
+ if (!state)
1276
+ return unknownPreparedCheckoutResult(reviewId);
1277
+ try {
1278
+ state.evidence.step('approval', {
1279
+ approved: false,
1280
+ reviewId,
1281
+ reason: detail,
1282
+ });
1283
+ state.evidence.setSnapshotSummary(await snapshotSummary(state.page));
1284
+ return makeResult('cancelled', state.fields, state.evidence, state.requiresAdapter, detail);
1285
+ }
1286
+ finally {
1287
+ await state.context.close().catch(() => { });
1288
+ }
1289
+ }
1290
+ // Backward-compatible one-shot API. It creates the same review and immediately
1291
+ // approves that exact review ID. Human-in-the-loop callers should use the
1292
+ // explicit prepareCheckout()/submitApprovedCheckout() pair instead.
1293
+ export async function runCheckout(opts, store = defaultPreparedCheckoutStore) {
1294
+ const { instrument, contact, mode, outcomeDeadlineMs, resolveEmailOtp, ...prepareOptions } = opts;
1295
+ const preparation = await prepareCheckout(prepareOptions, store);
1296
+ if (preparation.status === 'finished')
1297
+ return preparation.result;
1298
+ return submitApprovedCheckout(preparation.checkout.review.id, {
1299
+ approval: { approved: true, reviewId: preparation.checkout.review.id },
1300
+ instrument,
1301
+ contact,
1302
+ mode,
1303
+ outcomeDeadlineMs,
1304
+ ...(resolveEmailOtp ? { resolveEmailOtp } : {}),
1305
+ }, store);
1306
+ }