@agent-cards/checkout 0.2.0 → 0.3.0
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/CHANGELOG.md +25 -0
- package/README.md +191 -9
- package/dist/cdp.d.ts +73 -5
- package/dist/cdp.js +238 -25
- package/dist/client.d.ts +16 -4
- package/dist/client.js +132 -36
- package/dist/index.d.ts +4 -3
- package/dist/index.js +2 -2
- package/dist/lifecycle.d.ts +91 -0
- package/dist/lifecycle.js +193 -0
- package/examples/existing-browser.mjs +63 -0
- package/package.json +6 -3
package/dist/cdp.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { BUILTIN_REGISTRY, cardUrlPatterns } from './registry.js';
|
|
2
|
-
import { ApprovalDeclinedError, ApprovalTimeoutError, CardEncryptedError, CheckoutApiError, UnsupportedModeError, redactUrl, } from './client.js';
|
|
2
|
+
import { ApprovalDeclinedError, ApprovalTimeoutError, CardEncryptedError, CheckoutApiError, PaymentOutcomeUnknownError, UnsupportedModeError, redactUrl, } from './client.js';
|
|
3
3
|
import { substituteEncryptedFields } from './substitute.js';
|
|
4
4
|
import { hostedFormSubmittedPage } from './hosted-form.js';
|
|
5
|
+
import { CheckoutLifecycle, paymentEndpointGuards } from './lifecycle.js';
|
|
5
6
|
// Every URL that leaves this module through onEvent is redacted to origin +
|
|
6
7
|
// path first. A paused PaymentIntent confirm can carry the client secret in
|
|
7
8
|
// its query string (Stripe.js puts it in the body; hand-rolled runtimes and
|
|
@@ -141,6 +142,128 @@ function pausedBody(request) {
|
|
|
141
142
|
return Buffer.concat(entries.map((e) => Buffer.from(e.bytes, 'base64'))).toString('utf8');
|
|
142
143
|
}
|
|
143
144
|
const BODY_UNREADABLE_REASON = 'the paused request\'s body could not be read (no postData and no postDataEntries)';
|
|
145
|
+
/**
|
|
146
|
+
* The CORS headers a fulfilled CROSS-ORIGIN request needs, or null when the
|
|
147
|
+
* request is same-origin (or carries no Origin, so no CORS check applies).
|
|
148
|
+
*
|
|
149
|
+
* The browser checks a fulfilled response exactly as it checks a real one. A
|
|
150
|
+
* page that fetches a processor on another origin therefore needs
|
|
151
|
+
* `access-control-allow-origin` on the synthetic answer, or its fetch rejects
|
|
152
|
+
* with "Failed to fetch" and the page never sees the processor's reply, even
|
|
153
|
+
* though the cardholder approved and the processor answered the vault.
|
|
154
|
+
* Shopify never hit this on its current host: the checkout.pci.shopifyinc.com
|
|
155
|
+
* card iframe posts to its own origin (its older deposit.<region>.shopifycs.com
|
|
156
|
+
* host is called from the checkout.shopifycs.com frame, cross-origin, and gets
|
|
157
|
+
* the answer like everyone else). Stripe hits it on every surface (Checkout on
|
|
158
|
+
* checkout.stripe.com or a merchant domain, and Elements in the js.stripe.com
|
|
159
|
+
* frame, all call api.stripe.com), and so does every other processor whose
|
|
160
|
+
* card frame calls a separate API host. Observed live on
|
|
161
|
+
* 2026-09-03: the vault replayed a Stripe PaymentMethod into a raw-CDP
|
|
162
|
+
* runtime, the browser refused the answer for want of this header, and
|
|
163
|
+
* Stripe Checkout showed "We are experiencing connection issues".
|
|
164
|
+
*
|
|
165
|
+
* The exact Origin is echoed rather than `*`: a credentialed request refuses
|
|
166
|
+
* `*`, the echo satisfies both. The processor's own value can never reach
|
|
167
|
+
* this adapter (a browser does not expose that header to the page that
|
|
168
|
+
* replayed the call), so whatever the replay carries under these names is
|
|
169
|
+
* replaced by the one value that is right for THIS request. Playwright adds
|
|
170
|
+
* the same headers inside route.fulfill when a cross-origin fulfill carries
|
|
171
|
+
* none (microsoft/playwright#12929), which is why attachToPlaywright never
|
|
172
|
+
* needed this; it writes them itself anyway, replacing a stale value, so both
|
|
173
|
+
* adapters answer the vault's replays identically.
|
|
174
|
+
*
|
|
175
|
+
* This widens nothing. A tokenization endpoint is built for anonymous
|
|
176
|
+
* browsers and answers every origin (`access-control-allow-origin: *` on
|
|
177
|
+
* Stripe's and Shopify's own replies), so the page that made the request
|
|
178
|
+
* could always read the processor's answer to it; the replay is made exactly
|
|
179
|
+
* as visible, to exactly that page. Whether a card goes anywhere at all is
|
|
180
|
+
* decided by the cardholder on the approval screen, never by this header.
|
|
181
|
+
*
|
|
182
|
+
* Only what a browser serializes is ever echoed: one canonical http(s)
|
|
183
|
+
* origin (`new URL(origin).origin === origin`), or the opaque `null` a
|
|
184
|
+
* sandboxed or data: document sends, which Chrome matches against
|
|
185
|
+
* `access-control-allow-origin: null` and which Playwright echoes too. That
|
|
186
|
+
* refuses userinfo, a path, an explicit default port, several origins in one
|
|
187
|
+
* value, or a control character that would break the fulfill after the
|
|
188
|
+
* cardholder already approved. Anything refused simply gets no CORS answer,
|
|
189
|
+
* which is what every fulfill got before this existed.
|
|
190
|
+
*/
|
|
191
|
+
export function corsHeadersFor(url, requestHeaders) {
|
|
192
|
+
return corsDecision(url, requestHeaders).headers;
|
|
193
|
+
}
|
|
194
|
+
/** The CORS answer and its reason: `none` when no usable Origin was sent (or the url is not http(s)), `same_origin` when no check applies. */
|
|
195
|
+
export function corsDecision(url, requestHeaders) {
|
|
196
|
+
const none = { headers: null, outcome: 'none' };
|
|
197
|
+
const originEntry = Object.entries(requestHeaders ?? {}).find(([name]) => name.toLowerCase() === 'origin');
|
|
198
|
+
const origin = typeof originEntry?.[1] === 'string' ? originEntry[1].trim() : '';
|
|
199
|
+
if (!origin)
|
|
200
|
+
return none;
|
|
201
|
+
let target;
|
|
202
|
+
try {
|
|
203
|
+
target = new URL(url);
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return none;
|
|
207
|
+
}
|
|
208
|
+
if (target.protocol !== 'https:' && target.protocol !== 'http:')
|
|
209
|
+
return none;
|
|
210
|
+
if (origin !== 'null') {
|
|
211
|
+
let originUrl;
|
|
212
|
+
try {
|
|
213
|
+
originUrl = new URL(origin);
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return none;
|
|
217
|
+
}
|
|
218
|
+
if (originUrl.protocol !== 'https:' && originUrl.protocol !== 'http:')
|
|
219
|
+
return none;
|
|
220
|
+
if (originUrl.origin !== origin)
|
|
221
|
+
return none;
|
|
222
|
+
if (target.origin === origin)
|
|
223
|
+
return { headers: null, outcome: 'same_origin' };
|
|
224
|
+
}
|
|
225
|
+
return { headers: { 'access-control-allow-origin': origin, 'access-control-allow-credentials': 'true' }, outcome: 'echoed' };
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* `headers` with the CORS answer for this request written in; the object
|
|
229
|
+
* itself when none is needed. Any header the answer names is replaced
|
|
230
|
+
* whatever its case, so a name is never sent twice. The answer varies by
|
|
231
|
+
* Origin, and a `vary` the replay already carries is extended rather than
|
|
232
|
+
* replaced (or left alone when it already covers Origin or is `*`); a `vary`
|
|
233
|
+
* the answer itself names is taken as given.
|
|
234
|
+
*/
|
|
235
|
+
export function withCorsHeaders(headers, cors) {
|
|
236
|
+
if (!cors)
|
|
237
|
+
return headers;
|
|
238
|
+
const replaced = new Set(Object.keys(cors).map((name) => name.toLowerCase()));
|
|
239
|
+
const out = {};
|
|
240
|
+
let varyName = null;
|
|
241
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
242
|
+
const lower = name.toLowerCase();
|
|
243
|
+
if (replaced.has(lower))
|
|
244
|
+
continue;
|
|
245
|
+
if (lower === 'vary')
|
|
246
|
+
varyName = name;
|
|
247
|
+
out[name] = value;
|
|
248
|
+
}
|
|
249
|
+
Object.assign(out, cors);
|
|
250
|
+
if (replaced.has('vary'))
|
|
251
|
+
return out;
|
|
252
|
+
if (varyName === null) {
|
|
253
|
+
out.vary = 'Origin';
|
|
254
|
+
}
|
|
255
|
+
else {
|
|
256
|
+
const existing = String(out[varyName]);
|
|
257
|
+
const members = existing.split(',').map((m) => m.trim().toLowerCase());
|
|
258
|
+
if (!members.includes('origin') && !members.includes('*'))
|
|
259
|
+
out[varyName] = `${existing}, Origin`;
|
|
260
|
+
}
|
|
261
|
+
return out;
|
|
262
|
+
}
|
|
263
|
+
/** CDP's header shape: `{ name, value }` entries. */
|
|
264
|
+
function headerEntries(headers) {
|
|
265
|
+
return Object.entries(headers).map(([name, value]) => ({ name, value: String(value) }));
|
|
266
|
+
}
|
|
144
267
|
/**
|
|
145
268
|
* Last-resort patterns: the built-in recognizers' hosts, derived the same way
|
|
146
269
|
* as everything else. Used only when the vault hands back nothing at all (a
|
|
@@ -148,6 +271,19 @@ const BODY_UNREADABLE_REASON = 'the paused request\'s body could not be read (no
|
|
|
148
271
|
* that drifts from the registry is exactly the bug this adapter used to have.
|
|
149
272
|
*/
|
|
150
273
|
const FALLBACK_CARD_PATTERNS = cardUrlPatterns(BUILTIN_REGISTRY);
|
|
274
|
+
/** Observer exceptions and server error envelopes must not interrupt or leak a processor handoff. */
|
|
275
|
+
function safeOptions(opts) {
|
|
276
|
+
const observer = opts.onEvent;
|
|
277
|
+
return { ...opts, onEvent: (event) => { try {
|
|
278
|
+
Promise.resolve(observer?.(event)).catch(() => { });
|
|
279
|
+
}
|
|
280
|
+
catch { /* observer only */ } } };
|
|
281
|
+
}
|
|
282
|
+
function failureSummary(error) {
|
|
283
|
+
if (error instanceof CheckoutApiError)
|
|
284
|
+
return `${error.name}: ${error.code ?? `http_${error.status}`}`;
|
|
285
|
+
return error instanceof Error ? error.name : 'CheckoutError';
|
|
286
|
+
}
|
|
151
287
|
/**
|
|
152
288
|
* Take over card tokenization for a page.
|
|
153
289
|
*
|
|
@@ -166,6 +302,9 @@ const FALLBACK_CARD_PATTERNS = cardUrlPatterns(BUILTIN_REGISTRY);
|
|
|
166
302
|
* armed identically.
|
|
167
303
|
*/
|
|
168
304
|
export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
305
|
+
opts = safeOptions(opts);
|
|
306
|
+
const lifecycle = new CheckoutLifecycle(opts);
|
|
307
|
+
const guards = paymentEndpointGuards(opts.paymentEndpoints);
|
|
169
308
|
const armed = new Set();
|
|
170
309
|
// Set once a failure proves that retrying cannot help; see isTerminal.
|
|
171
310
|
let terminal = null;
|
|
@@ -178,25 +317,34 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
178
317
|
const repeatQuietMs = opts.hostedFormRepeatQuietMs ?? HOSTED_FORM_REPEAT_QUIET_MS;
|
|
179
318
|
let lastSubmitted = null;
|
|
180
319
|
const derived = typeof opts.vault?.cardUrlPatterns === 'function' ? opts.vault.cardUrlPatterns() : [];
|
|
181
|
-
const urlPatterns = derived.length > 0 ? derived : FALLBACK_CARD_PATTERNS;
|
|
320
|
+
const urlPatterns = [...new Set([...(derived.length > 0 ? derived : FALLBACK_CARD_PATTERNS), ...guards.patterns])];
|
|
182
321
|
opts.onEvent?.({ type: 'fetch_armed', detail: { patterns: urlPatterns } });
|
|
183
322
|
const arm = async (sessionId) => {
|
|
184
323
|
const key = sessionId ?? '__root__';
|
|
185
324
|
if (armed.has(key))
|
|
186
325
|
return;
|
|
187
|
-
armed.add(key);
|
|
188
326
|
await cdp.send('Fetch.enable', {
|
|
189
327
|
patterns: urlPatterns.map((urlPattern) => ({ urlPattern, requestStage: 'Request' })),
|
|
190
|
-
}, sessionId)
|
|
328
|
+
}, sessionId);
|
|
191
329
|
// Descend into this target's own children (iframes inside iframes).
|
|
192
330
|
await cdp.send('Target.setAutoAttach', {
|
|
193
331
|
autoAttach: true, waitForDebuggerOnStart: true, flatten: true,
|
|
194
|
-
}, sessionId)
|
|
332
|
+
}, sessionId);
|
|
333
|
+
armed.add(key);
|
|
195
334
|
};
|
|
196
335
|
cdp.on(async (method, params, sessionId) => {
|
|
197
336
|
if (method === 'Target.attachedToTarget') {
|
|
198
337
|
const child = params.sessionId;
|
|
199
|
-
|
|
338
|
+
try {
|
|
339
|
+
await arm(child);
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
terminal = new Error('browser_interception_unavailable');
|
|
343
|
+
lifecycle.failed(new PaymentOutcomeUnknownError(lifecycle.getState().authorizationId, 'browser_interception_unavailable'));
|
|
344
|
+
opts.onEvent?.({ type: 'failed', detail: 'browser_interception_unavailable' });
|
|
345
|
+
// Leave this target paused: resuming an unarmed card frame would silently bypass the vault.
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
200
348
|
// Child targets start paused when waitForDebuggerOnStart is set.
|
|
201
349
|
await cdp.send('Runtime.runIfWaitingForDebugger', {}, child).catch(() => { });
|
|
202
350
|
return;
|
|
@@ -205,14 +353,20 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
205
353
|
return;
|
|
206
354
|
const { requestId, request, resourceType } = params;
|
|
207
355
|
if (!opts.vault.isCardRequest(request.url, request.method)) {
|
|
356
|
+
if (guards.matches(request.url, request.method)) {
|
|
357
|
+
lifecycle.unsupported();
|
|
358
|
+
opts.onEvent?.({ type: 'unsupported_checkout', detail: { url: redactUrl(request.url), method: request.method } });
|
|
359
|
+
await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
208
362
|
await cdp.send('Fetch.continueRequest', { requestId }, sessionId).catch(() => { });
|
|
209
363
|
return;
|
|
210
364
|
}
|
|
211
365
|
// Same stop condition as the Playwright adapter: once a failure proves
|
|
212
366
|
// retrying is pointless, fail the request without calling the API again.
|
|
213
|
-
if (terminal || awaitingApproval || Date.now() < quietUntil) {
|
|
214
|
-
const why = terminal ?? (awaitingApproval ? 'an approval is already outstanding' : 'awaiting approval cooldown');
|
|
215
|
-
opts.onEvent?.({ type: 'blocked', detail: String(why) });
|
|
367
|
+
if (terminal || lifecycle.isBlocked() || awaitingApproval || Date.now() < quietUntil) {
|
|
368
|
+
const why = terminal ?? (lifecycle.isBlocked() ? lifecycle.getState().status : awaitingApproval ? 'an approval is already outstanding' : 'awaiting approval cooldown');
|
|
369
|
+
opts.onEvent?.({ type: 'blocked', detail: why instanceof Error ? failureSummary(why) : String(why) });
|
|
216
370
|
await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
|
|
217
371
|
return;
|
|
218
372
|
}
|
|
@@ -220,6 +374,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
220
374
|
// requests can never both clear the check above and raise two prompts for
|
|
221
375
|
// one checkout.
|
|
222
376
|
awaitingApproval = true;
|
|
377
|
+
let handoffStarted = false;
|
|
223
378
|
try {
|
|
224
379
|
const body = pausedBody(request);
|
|
225
380
|
if (body === null) {
|
|
@@ -239,15 +394,24 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
239
394
|
return;
|
|
240
395
|
}
|
|
241
396
|
opts.onEvent?.({ type: 'card_request_paused', detail: { url: redactUrl(request.url), ...(resourceType ? { resourceType } : {}) } });
|
|
397
|
+
lifecycle.begin();
|
|
242
398
|
const replay = await opts.vault.authorize({
|
|
243
399
|
user: opts.user,
|
|
244
400
|
merchant: opts.merchant,
|
|
245
401
|
amount: opts.amount,
|
|
246
402
|
amountCents: opts.amountCents,
|
|
247
403
|
currency: opts.currency,
|
|
248
|
-
|
|
404
|
+
cardId: opts.cardId,
|
|
405
|
+
timeoutMs: opts.timeoutMs,
|
|
406
|
+
signal: lifecycle.abort.signal,
|
|
407
|
+
onAuthorizationCreated: (id) => lifecycle.approvalCreated(id),
|
|
408
|
+
onApprovalUrl: (url) => { lifecycle.approvalUrl(url); return opts.onApprovalUrl?.(url); },
|
|
249
409
|
request: { url: request.url, method: request.method, headers: request.headers, body },
|
|
250
410
|
});
|
|
411
|
+
if (lifecycle.isCancelled())
|
|
412
|
+
throw new Error('checkout cancelled locally after approval');
|
|
413
|
+
lifecycle.prepareHandoff(replay, request.url);
|
|
414
|
+
handoffStarted = replay.mode !== 'cse';
|
|
251
415
|
if (replay.mode === 'hosted_form') {
|
|
252
416
|
// The device submitted the processor's own form; the processor
|
|
253
417
|
// answered the device. The paused NAVIGATION is fulfilled with a page
|
|
@@ -255,10 +419,12 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
255
419
|
// why not a fake result page), and the same form is refused if the
|
|
256
420
|
// page posts it again.
|
|
257
421
|
const page = hostedFormSubmittedPage({ authorizationId: replay.authorizationId, merchant: opts.merchant, submittedAt: replay.submittedAt });
|
|
422
|
+
// A navigation response is never CORS-checked, so the CORS wrap is
|
|
423
|
+
// inert here; it is applied so every fulfill goes through one path.
|
|
258
424
|
await cdp.send('Fetch.fulfillRequest', {
|
|
259
425
|
requestId,
|
|
260
426
|
responseCode: page.status,
|
|
261
|
-
responseHeaders:
|
|
427
|
+
responseHeaders: headerEntries(withCorsHeaders(page.headers, corsHeadersFor(request.url, request.headers))),
|
|
262
428
|
body: Buffer.from(page.body).toString('base64'),
|
|
263
429
|
}, sessionId);
|
|
264
430
|
lastSubmitted = { url: request.url, body, at: Date.now() };
|
|
@@ -272,35 +438,48 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
272
438
|
// and cookies, and only the four ciphertext fields swapped in. Only
|
|
273
439
|
// postData rides on the command: no header override, ever (see
|
|
274
440
|
// cseBody for why a recomputed Content-Length is refused by Chromium).
|
|
441
|
+
const postData = Buffer.from(cseBody(body, replay)).toString('base64');
|
|
442
|
+
handoffStarted = true;
|
|
275
443
|
await cdp.send('Fetch.continueRequest', {
|
|
276
444
|
requestId,
|
|
277
|
-
postData
|
|
445
|
+
postData,
|
|
278
446
|
}, sessionId);
|
|
279
447
|
opts.onEvent?.({ type: 'authorized', detail: { mode: 'cse', authorizationId: replay.authorizationId, fields: Object.keys(replay.substitutions.fields) } });
|
|
280
448
|
}
|
|
281
449
|
else {
|
|
450
|
+
// The processor's answer, handed to the page as if the processor had
|
|
451
|
+
// sent it. Cross-origin, the browser then runs its CORS check on it:
|
|
452
|
+
// see corsHeadersFor for why the answer has to carry the headers. The
|
|
453
|
+
// decision rides on the event, so a fulfill the page could not read
|
|
454
|
+
// (no usable Origin on a cross-origin request) is visible in telemetry
|
|
455
|
+
// rather than only as the page's own "connection error".
|
|
456
|
+
const cors = corsDecision(request.url, request.headers);
|
|
282
457
|
await cdp.send('Fetch.fulfillRequest', {
|
|
283
458
|
requestId,
|
|
284
459
|
responseCode: replay.status,
|
|
285
|
-
responseHeaders:
|
|
460
|
+
responseHeaders: headerEntries(withCorsHeaders(replay.headers, cors.headers)),
|
|
286
461
|
body: Buffer.from(replay.body).toString('base64'),
|
|
287
462
|
}, sessionId);
|
|
288
|
-
opts.onEvent?.({ type: 'authorized', detail: { mode: 'token', authorizationId: replay.authorizationId, amountVerified: replay.amountVerified ?? null } });
|
|
463
|
+
opts.onEvent?.({ type: 'authorized', detail: { mode: 'token', authorizationId: replay.authorizationId, amountVerified: replay.amountVerified ?? null, cors: cors.outcome } });
|
|
289
464
|
}
|
|
465
|
+
lifecycle.handedOff(replay);
|
|
290
466
|
}
|
|
291
467
|
catch (err) {
|
|
468
|
+
lifecycle.failed(err, handoffStarted);
|
|
292
469
|
if (isTerminal(err))
|
|
293
470
|
terminal = err;
|
|
294
471
|
else if (isApprovalOutcome(err))
|
|
295
472
|
quietUntil = Date.now() + cooldownMs;
|
|
296
|
-
opts.onEvent?.({ type: 'failed', detail:
|
|
473
|
+
opts.onEvent?.({ type: 'failed', detail: failureSummary(err) });
|
|
297
474
|
await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
|
|
298
475
|
}
|
|
299
476
|
finally {
|
|
300
477
|
awaitingApproval = false;
|
|
478
|
+
lifecycle.end();
|
|
301
479
|
}
|
|
302
480
|
});
|
|
303
481
|
await arm(pageSessionId);
|
|
482
|
+
return lifecycle;
|
|
304
483
|
}
|
|
305
484
|
/**
|
|
306
485
|
* Playwright convenience wrapper — the path for cloud browsers that hand you a
|
|
@@ -314,6 +493,14 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
314
493
|
* the note in the body for why a hand-rolled CDPSession does not work here.
|
|
315
494
|
*/
|
|
316
495
|
export async function attachToPlaywright(page, opts) {
|
|
496
|
+
opts = safeOptions(opts);
|
|
497
|
+
// Routing cannot see requests owned by a service worker. Existing controlled
|
|
498
|
+
// contexts must be recreated with serviceWorkers: 'block' before checkout.
|
|
499
|
+
if (page.context?.().serviceWorkers?.().length) {
|
|
500
|
+
throw new Error('Service workers are active; use a checkout context created with serviceWorkers: "block".');
|
|
501
|
+
}
|
|
502
|
+
const lifecycle = new CheckoutLifecycle(opts);
|
|
503
|
+
const guards = paymentEndpointGuards(opts.paymentEndpoints);
|
|
317
504
|
// Playwright's own routing, NOT a hand-rolled CDP session.
|
|
318
505
|
//
|
|
319
506
|
// A CDPSession from `newCDPSession(page)` is bound to the PAGE target and its
|
|
@@ -336,11 +523,16 @@ export async function attachToPlaywright(page, opts) {
|
|
|
336
523
|
// The hosted form the cardholder already submitted; see HOSTED_FORM_REPEAT_QUIET_MS.
|
|
337
524
|
const repeatQuietMs = opts.hostedFormRepeatQuietMs ?? HOSTED_FORM_REPEAT_QUIET_MS;
|
|
338
525
|
let lastSubmitted = null;
|
|
339
|
-
await page.route((url) => opts.vault.isCardRequest(url.toString()), async (route) => {
|
|
526
|
+
await page.route((url) => opts.vault.isCardRequest(url.toString()) || guards.matches(url.toString()), async (route) => {
|
|
340
527
|
const request = route.request();
|
|
341
528
|
// The matcher only sees the URL; a preflight or a GET must pass through
|
|
342
529
|
// untouched or the browser's CORS check fails on our synthetic answer.
|
|
343
530
|
if (!opts.vault.isCardRequest(request.url(), request.method())) {
|
|
531
|
+
if (guards.matches(request.url(), request.method())) {
|
|
532
|
+
lifecycle.unsupported();
|
|
533
|
+
opts.onEvent?.({ type: 'unsupported_checkout', detail: { url: redactUrl(request.url()), method: request.method() } });
|
|
534
|
+
return route.abort('aborted');
|
|
535
|
+
}
|
|
344
536
|
return route.fallback();
|
|
345
537
|
}
|
|
346
538
|
// Fail closed and stay quiet: no card may reach the PSP, but neither may
|
|
@@ -348,13 +540,14 @@ export async function attachToPlaywright(page, opts) {
|
|
|
348
540
|
// abort in this adapter is 'aborted' (ERR_ABORTED), the same code the
|
|
349
541
|
// CDP adapter's Fetch.failRequest uses, so a refused navigation
|
|
350
542
|
// resolves identically whichever adapter is attached.
|
|
351
|
-
if (terminal || awaitingApproval || Date.now() < quietUntil) {
|
|
352
|
-
const why = terminal ?? (awaitingApproval ? 'an approval is already outstanding' : 'awaiting approval cooldown');
|
|
353
|
-
opts.onEvent?.({ type: 'blocked', detail: String(why) });
|
|
543
|
+
if (terminal || lifecycle.isBlocked() || awaitingApproval || Date.now() < quietUntil) {
|
|
544
|
+
const why = terminal ?? (lifecycle.isBlocked() ? lifecycle.getState().status : awaitingApproval ? 'an approval is already outstanding' : 'awaiting approval cooldown');
|
|
545
|
+
opts.onEvent?.({ type: 'blocked', detail: why instanceof Error ? failureSummary(why) : String(why) });
|
|
354
546
|
return route.abort('aborted');
|
|
355
547
|
}
|
|
356
548
|
// Reserved before anything that could yield, matching attachToCdp.
|
|
357
549
|
awaitingApproval = true;
|
|
550
|
+
let handoffStarted = false;
|
|
358
551
|
try {
|
|
359
552
|
const body = request.postData() ?? '';
|
|
360
553
|
if (isRepeatOfSubmitted(lastSubmitted, request.url(), body, repeatQuietMs)) {
|
|
@@ -362,20 +555,30 @@ export async function attachToPlaywright(page, opts) {
|
|
|
362
555
|
return await route.abort('aborted');
|
|
363
556
|
}
|
|
364
557
|
opts.onEvent?.({ type: 'card_request_paused', detail: { url: redactUrl(request.url()) } });
|
|
558
|
+
lifecycle.begin();
|
|
365
559
|
const replay = await opts.vault.authorize({
|
|
366
560
|
user: opts.user,
|
|
367
561
|
merchant: opts.merchant,
|
|
368
562
|
amount: opts.amount,
|
|
369
563
|
amountCents: opts.amountCents,
|
|
370
564
|
currency: opts.currency,
|
|
371
|
-
|
|
565
|
+
cardId: opts.cardId,
|
|
566
|
+
timeoutMs: opts.timeoutMs,
|
|
567
|
+
signal: lifecycle.abort.signal,
|
|
568
|
+
onAuthorizationCreated: (id) => lifecycle.approvalCreated(id),
|
|
569
|
+
onApprovalUrl: (url) => { lifecycle.approvalUrl(url); return opts.onApprovalUrl?.(url); },
|
|
372
570
|
request: { url: request.url(), method: request.method(), headers: request.headers(), body },
|
|
373
571
|
});
|
|
572
|
+
if (lifecycle.isCancelled())
|
|
573
|
+
throw new Error('checkout cancelled locally after approval');
|
|
574
|
+
lifecycle.prepareHandoff(replay, request.url());
|
|
575
|
+
handoffStarted = replay.mode !== 'cse';
|
|
374
576
|
if (replay.mode === 'hosted_form') {
|
|
375
577
|
// Same as the CDP path: the paused navigation resolves to the
|
|
376
578
|
// synthetic page, and a re-post of this form is refused.
|
|
377
579
|
const synthetic = hostedFormSubmittedPage({ authorizationId: replay.authorizationId, merchant: opts.merchant, submittedAt: replay.submittedAt });
|
|
378
|
-
|
|
580
|
+
// Inert on a navigation (never CORS-checked); one path for every fulfill.
|
|
581
|
+
await route.fulfill({ status: synthetic.status, headers: withCorsHeaders(synthetic.headers, corsHeadersFor(request.url(), request.headers())), body: synthetic.body });
|
|
379
582
|
lastSubmitted = { url: request.url(), body, at: Date.now() };
|
|
380
583
|
opts.onEvent?.({ type: 'submitted_on_device', detail: { authorizationId: replay.authorizationId, submittedAt: replay.submittedAt, outcome: replay.outcome } });
|
|
381
584
|
}
|
|
@@ -383,24 +586,34 @@ export async function attachToPlaywright(page, opts) {
|
|
|
383
586
|
// Same as the CDP path: the request continues from this browser
|
|
384
587
|
// with the ciphertext swapped in and no header override; Playwright
|
|
385
588
|
// recomputes the length itself.
|
|
386
|
-
|
|
589
|
+
const postData = cseBody(body, replay);
|
|
590
|
+
handoffStarted = true;
|
|
591
|
+
await route.continue({ postData });
|
|
387
592
|
opts.onEvent?.({ type: 'authorized', detail: { mode: 'cse', authorizationId: replay.authorizationId, fields: Object.keys(replay.substitutions.fields) } });
|
|
388
593
|
}
|
|
389
594
|
else {
|
|
390
|
-
|
|
391
|
-
|
|
595
|
+
// Playwright adds these itself when a cross-origin fulfill carries
|
|
596
|
+
// none; written here anyway (replacing a stale value) so a
|
|
597
|
+
// cross-origin answer is the same whichever adapter ran.
|
|
598
|
+
const cors = corsDecision(request.url(), request.headers());
|
|
599
|
+
await route.fulfill({ status: replay.status, headers: withCorsHeaders(replay.headers, cors.headers), body: replay.body });
|
|
600
|
+
opts.onEvent?.({ type: 'authorized', detail: { mode: 'token', authorizationId: replay.authorizationId, amountVerified: replay.amountVerified ?? null, cors: cors.outcome } });
|
|
392
601
|
}
|
|
602
|
+
lifecycle.handedOff(replay);
|
|
393
603
|
}
|
|
394
604
|
catch (err) {
|
|
605
|
+
lifecycle.failed(err, handoffStarted);
|
|
395
606
|
if (isTerminal(err))
|
|
396
607
|
terminal = err;
|
|
397
608
|
else if (isApprovalOutcome(err))
|
|
398
609
|
quietUntil = Date.now() + cooldownMs;
|
|
399
|
-
opts.onEvent?.({ type: 'failed', detail:
|
|
610
|
+
opts.onEvent?.({ type: 'failed', detail: failureSummary(err) });
|
|
400
611
|
await route.abort('aborted');
|
|
401
612
|
}
|
|
402
613
|
finally {
|
|
403
614
|
awaitingApproval = false;
|
|
615
|
+
lifecycle.end();
|
|
404
616
|
}
|
|
405
617
|
});
|
|
618
|
+
return lifecycle;
|
|
406
619
|
}
|
package/dist/client.d.ts
CHANGED
|
@@ -133,6 +133,10 @@ export interface AuthorizeInput {
|
|
|
133
133
|
request: PausedRequest;
|
|
134
134
|
/** Abort if the user has not approved within this many ms. Default 15 min. */
|
|
135
135
|
timeoutMs?: number;
|
|
136
|
+
/** Stops local polling; it does not revoke a pending approval or undo a payment. */
|
|
137
|
+
signal?: AbortSignal;
|
|
138
|
+
/** Called before onApprovalUrl; lets a runtime reconcile an interrupted authorization. */
|
|
139
|
+
onAuthorizationCreated?: (authorizationId: string) => void;
|
|
136
140
|
/** Called once with the URL to surface to the user, if you deliver it yourself. */
|
|
137
141
|
onApprovalUrl?: (url: string) => void;
|
|
138
142
|
}
|
|
@@ -141,10 +145,9 @@ export declare class CardEncryptedError extends Error {
|
|
|
141
145
|
constructor(psp: string);
|
|
142
146
|
}
|
|
143
147
|
/**
|
|
144
|
-
* The
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
* same page could only raise more prompts for the same dead end.
|
|
148
|
+
* The registry requests a mode this SDK build cannot finish, before creation.
|
|
149
|
+
* A response in an unexpected mode after creation has an unknown payment
|
|
150
|
+
* outcome instead and raises PaymentOutcomeUnknownError.
|
|
148
151
|
*/
|
|
149
152
|
export declare class UnsupportedModeError extends Error {
|
|
150
153
|
mode: string;
|
|
@@ -153,6 +156,15 @@ export declare class UnsupportedModeError extends Error {
|
|
|
153
156
|
export declare class ApprovalTimeoutError extends Error {
|
|
154
157
|
constructor(ms: number);
|
|
155
158
|
}
|
|
159
|
+
export declare class CheckoutCancelledError extends Error {
|
|
160
|
+
constructor();
|
|
161
|
+
}
|
|
162
|
+
/** The payment may have reached the processor. Reconcile the merchant order before any new attempt. */
|
|
163
|
+
export declare class PaymentOutcomeUnknownError extends Error {
|
|
164
|
+
authorizationId: string | null;
|
|
165
|
+
reason: string;
|
|
166
|
+
constructor(authorizationId: string | null, reason: string);
|
|
167
|
+
}
|
|
156
168
|
export declare class ApprovalDeclinedError extends Error {
|
|
157
169
|
constructor(reason: string);
|
|
158
170
|
}
|