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

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