@visa/cli 4.1.0-rc.298 → 4.1.0-rc.299

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