@visa/cli 4.1.0-rc.8 → 4.1.0-rc.80

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