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