@agent-cards/checkout 0.1.0 → 0.2.1
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 +174 -6
- package/dist/cdp.d.ts +87 -2
- package/dist/cdp.js +445 -26
- package/dist/client.d.ts +268 -3
- package/dist/client.js +360 -12
- package/dist/hosted-form.d.ts +44 -0
- package/dist/hosted-form.js +78 -0
- package/dist/index.d.ts +10 -6
- package/dist/index.js +5 -3
- package/dist/registry.d.ts +50 -1
- package/dist/registry.js +436 -4
- package/dist/substitute.d.ts +42 -0
- package/dist/substitute.js +87 -0
- package/package.json +1 -1
package/dist/cdp.js
CHANGED
|
@@ -1,11 +1,275 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
1
|
+
import { BUILTIN_REGISTRY, cardUrlPatterns } from './registry.js';
|
|
2
|
+
import { ApprovalDeclinedError, ApprovalTimeoutError, CardEncryptedError, CheckoutApiError, UnsupportedModeError, redactUrl, } from './client.js';
|
|
3
|
+
import { substituteEncryptedFields } from './substitute.js';
|
|
4
|
+
import { hostedFormSubmittedPage } from './hosted-form.js';
|
|
5
|
+
// Every URL that leaves this module through onEvent is redacted to origin +
|
|
6
|
+
// path first. A paused PaymentIntent confirm can carry the client secret in
|
|
7
|
+
// its query string (Stripe.js puts it in the body; hand-rolled runtimes and
|
|
8
|
+
// some SDKs put it in the URL), and onEvent is ordinary integrator telemetry:
|
|
9
|
+
// logs, dashboards, crash reporters. None of those may receive a secret.
|
|
10
|
+
/**
|
|
11
|
+
* How long a hosted form that the cardholder already submitted stays refused
|
|
12
|
+
* when the page posts it again.
|
|
13
|
+
*
|
|
14
|
+
* A hosted_form approval resolves the paused navigation with a synthetic page
|
|
15
|
+
* (hostedFormSubmittedPage), and the merchant page may still carry the same
|
|
16
|
+
* card form: an agent that clicks Pay again would pause an identical
|
|
17
|
+
* navigation and, without this, raise a second prompt on the household's
|
|
18
|
+
* phone for a payment that already left it. The API's duplicate guard covers
|
|
19
|
+
* a body the form regenerated (same supplier, session and amount inside 15
|
|
20
|
+
* minutes: 409 duplicate_submission, which the adapters quiet the way they
|
|
21
|
+
* quiet a decline, see isApprovalOutcome); this latch covers the
|
|
22
|
+
* byte-identical re-post without a round trip. Same window as the
|
|
23
|
+
* authorization's own TTL. Overridable per attach via `hostedFormRepeatQuietMs`.
|
|
24
|
+
*/
|
|
25
|
+
const HOSTED_FORM_REPEAT_QUIET_MS = 15 * 60_000;
|
|
26
|
+
/** The same form the device already submitted, posted again inside the quiet window. */
|
|
27
|
+
function isRepeatOfSubmitted(last, url, body, quietMs) {
|
|
28
|
+
return !!last && last.url === url && last.body === body && Date.now() - last.at < quietMs;
|
|
29
|
+
}
|
|
30
|
+
const HOSTED_FORM_REPEAT_REASON = 'already submitted on the cardholder\'s device';
|
|
31
|
+
/**
|
|
32
|
+
* How long to stop asking after a person declines or ignores an approval.
|
|
33
|
+
*
|
|
34
|
+
* This exists because the two failure shapes look identical at the route
|
|
35
|
+
* handler and need opposite answers. A merchant page re-issues an aborted
|
|
36
|
+
* tokenization within milliseconds, and re-prompting on each of those turns
|
|
37
|
+
* one decline into a queue of notifications on someone's phone. A genuinely
|
|
38
|
+
* later checkout also arrives as a paused request, and that one deserves a
|
|
39
|
+
* fresh prompt, so a permanent latch is wrong too.
|
|
40
|
+
*
|
|
41
|
+
* Elapsed time is what separates them, and nothing else available here does:
|
|
42
|
+
* body, nonce and authorization id all differ between an automatic retry and a
|
|
43
|
+
* new checkout. So a decline buys a short silence, not a closed door.
|
|
44
|
+
*
|
|
45
|
+
* Kept deliberately SHORT. A page's automatic retry lands in milliseconds — the
|
|
46
|
+
* storm this exists for ran at roughly ten a second — so a few seconds absorbs
|
|
47
|
+
* a burst with room to spare, while leaving almost no window in which a person
|
|
48
|
+
* could start a genuinely new checkout and be turned away. Raise it only with
|
|
49
|
+
* evidence of a slower retry loop; every second added is a second a real
|
|
50
|
+
* checkout can be refused. Overridable per attach via `approvalCooldownMs`.
|
|
51
|
+
*/
|
|
52
|
+
const APPROVAL_COOLDOWN_MS = 5_000;
|
|
53
|
+
/**
|
|
54
|
+
* One approval at a time, per attachment.
|
|
55
|
+
*
|
|
56
|
+
* The cooldown only arms once a failure RESOLVES, so two requests paused
|
|
57
|
+
* before the first authorize() returns both sail past it and each raise their
|
|
58
|
+
* own prompt. A checkout with several card iframes does exactly that. Nobody
|
|
59
|
+
* should get two notifications asking them to approve the same purchase, so a
|
|
60
|
+
* request arriving while one is outstanding is failed rather than queued: the
|
|
61
|
+
* page's own retry brings it back, and by then there is an answer.
|
|
62
|
+
*/
|
|
63
|
+
/**
|
|
64
|
+
* This one authorization was answered; see APPROVAL_COOLDOWN_MS. An
|
|
65
|
+
* AmountMismatchError is an ApprovalDeclinedError, so both amount checks land
|
|
66
|
+
* here: the pre-replay one (the row IS declined) and the create-time one (no
|
|
67
|
+
* row; the intent already disagreed). Either way the paused request is
|
|
68
|
+
* aborted with nothing charged and the page's retry is quieted, NOT latched:
|
|
69
|
+
* Stripe lets the merchant update an intent's amount until it is confirmed,
|
|
70
|
+
* so the next request on this page is a new question and is judged afresh.
|
|
71
|
+
* IntentNotConfirmableError lands here too (the row is declined).
|
|
72
|
+
*
|
|
73
|
+
* A 409 duplicate_submission is an answer of the same kind: the household
|
|
74
|
+
* already has, or already answered, the prompt for this exact submission (a
|
|
75
|
+
* hosted form the page posted again with a fresh nonce). Quieting the page's
|
|
76
|
+
* re-post is right; latching the whole attachment is not, because the prior
|
|
77
|
+
* row declines or expires and the same form is then a new question. The
|
|
78
|
+
* quiet window absorbs the burst; the next request past it is judged afresh.
|
|
79
|
+
*/
|
|
80
|
+
function isApprovalOutcome(err) {
|
|
81
|
+
if (err instanceof CheckoutApiError)
|
|
82
|
+
return err.code === 'duplicate_submission';
|
|
83
|
+
return err instanceof ApprovalDeclinedError || err instanceof ApprovalTimeoutError;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Should this failure stop us intercepting for the rest of the page's life?
|
|
87
|
+
*
|
|
88
|
+
* It matters because a failed tokenization does not end the checkout: the
|
|
89
|
+
* merchant's own page retries, we pause the retry, we fail it again, and the
|
|
90
|
+
* loop runs as fast as the page will go. A real run against Shopify with a
|
|
91
|
+
* stale user id produced ~10 authorization calls a SECOND for fifteen minutes
|
|
92
|
+
* until the agent's host killed it, every one of them a request to our API that
|
|
93
|
+
* could never have succeeded.
|
|
94
|
+
*
|
|
95
|
+
* Terminal means "will answer identically next time no matter who does what":
|
|
96
|
+
* a misconfiguration or an unsupported PSP. Nothing a person does changes
|
|
97
|
+
* those, so asking again is pure waste.
|
|
98
|
+
*
|
|
99
|
+
* Everything else stays retryable. A 5xx or a 429 clears on its own, and a
|
|
100
|
+
* decline or a timeout is answered by the cooldown above rather than by
|
|
101
|
+
* killing the page: the person said no to one authorization, not to every
|
|
102
|
+
* checkout they will ever make in this session. A 409 duplicate_submission
|
|
103
|
+
* is not `permanent` on the error itself (see CheckoutApiError) and lands in
|
|
104
|
+
* the cooldown too.
|
|
105
|
+
*/
|
|
106
|
+
function isTerminal(err) {
|
|
107
|
+
if (err instanceof CheckoutApiError)
|
|
108
|
+
return err.permanent;
|
|
109
|
+
return err instanceof CardEncryptedError || err instanceof UnsupportedModeError;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* The cse continuation: the paused body with the vault's ciphertext in place
|
|
113
|
+
* of the dummy blobs. It is sent as postData ALONE. Chromium recomputes
|
|
114
|
+
* Content-Length for a continued request itself and refuses a header override
|
|
115
|
+
* that names it (Fetch.continueRequest answers -32602 "Unsafe header"), and
|
|
116
|
+
* that refusal would land after the cardholder approved, failing a paid-for
|
|
117
|
+
* request. Leaving `headers` off the command keeps every other header the
|
|
118
|
+
* browser's own, untouched, which is the point of continuing rather than
|
|
119
|
+
* fulfilling.
|
|
120
|
+
*/
|
|
121
|
+
function cseBody(body, replay) {
|
|
122
|
+
return substituteEncryptedFields(body, replay.substitutions);
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* The paused request's body, or null when Chromium says there is one and did
|
|
126
|
+
* not hand it over. `postData` is set only for a text body; a binary or a
|
|
127
|
+
* large one arrives as base64 `postDataEntries` instead, concatenated here
|
|
128
|
+
* byte for byte (an entry without `bytes` is a file the browser streams, which
|
|
129
|
+
* no runtime can read back). There is no CDP command to fetch it later:
|
|
130
|
+
* `Fetch.getRequestPostData` does not exist (Chrome answers -32601), so a
|
|
131
|
+
* body that is not on the event is a body this adapter cannot see.
|
|
132
|
+
*/
|
|
133
|
+
function pausedBody(request) {
|
|
134
|
+
if (typeof request.postData === 'string' && request.postData !== '')
|
|
135
|
+
return request.postData;
|
|
136
|
+
if (!request.hasPostData)
|
|
137
|
+
return request.postData ?? '';
|
|
138
|
+
const entries = request.postDataEntries ?? [];
|
|
139
|
+
if (!entries.length || entries.some((e) => typeof e.bytes !== 'string'))
|
|
140
|
+
return null;
|
|
141
|
+
return Buffer.concat(entries.map((e) => Buffer.from(e.bytes, 'base64'))).toString('utf8');
|
|
142
|
+
}
|
|
143
|
+
const BODY_UNREADABLE_REASON = 'the paused request\'s body could not be read (no postData and no postDataEntries)';
|
|
144
|
+
/**
|
|
145
|
+
* The CORS headers a fulfilled CROSS-ORIGIN request needs, or null when the
|
|
146
|
+
* request is same-origin (or carries no Origin, so no CORS check applies).
|
|
147
|
+
*
|
|
148
|
+
* The browser checks a fulfilled response exactly as it checks a real one. A
|
|
149
|
+
* page that fetches a processor on another origin therefore needs
|
|
150
|
+
* `access-control-allow-origin` on the synthetic answer, or its fetch rejects
|
|
151
|
+
* with "Failed to fetch" and the page never sees the processor's reply, even
|
|
152
|
+
* though the cardholder approved and the processor answered the vault.
|
|
153
|
+
* Shopify never hit this on its current host: the checkout.pci.shopifyinc.com
|
|
154
|
+
* card iframe posts to its own origin (its older deposit.<region>.shopifycs.com
|
|
155
|
+
* host is called from the checkout.shopifycs.com frame, cross-origin, and gets
|
|
156
|
+
* the answer like everyone else). Stripe hits it on every surface (Checkout on
|
|
157
|
+
* checkout.stripe.com or a merchant domain, and Elements in the js.stripe.com
|
|
158
|
+
* frame, all call api.stripe.com), and so does every other processor whose
|
|
159
|
+
* card frame calls a separate API host. Observed live on
|
|
160
|
+
* 2026-09-03: the vault replayed a Stripe PaymentMethod into a raw-CDP
|
|
161
|
+
* runtime, the browser refused the answer for want of this header, and
|
|
162
|
+
* Stripe Checkout showed "We are experiencing connection issues".
|
|
163
|
+
*
|
|
164
|
+
* The exact Origin is echoed rather than `*`: a credentialed request refuses
|
|
165
|
+
* `*`, the echo satisfies both. The processor's own value can never reach
|
|
166
|
+
* this adapter (a browser does not expose that header to the page that
|
|
167
|
+
* replayed the call), so whatever the replay carries under these names is
|
|
168
|
+
* replaced by the one value that is right for THIS request. Playwright adds
|
|
169
|
+
* the same headers inside route.fulfill when a cross-origin fulfill carries
|
|
170
|
+
* none (microsoft/playwright#12929), which is why attachToPlaywright never
|
|
171
|
+
* needed this; it writes them itself anyway, replacing a stale value, so both
|
|
172
|
+
* adapters answer the vault's replays identically.
|
|
173
|
+
*
|
|
174
|
+
* This widens nothing. A tokenization endpoint is built for anonymous
|
|
175
|
+
* browsers and answers every origin (`access-control-allow-origin: *` on
|
|
176
|
+
* Stripe's and Shopify's own replies), so the page that made the request
|
|
177
|
+
* could always read the processor's answer to it; the replay is made exactly
|
|
178
|
+
* as visible, to exactly that page. Whether a card goes anywhere at all is
|
|
179
|
+
* decided by the cardholder on the approval screen, never by this header.
|
|
180
|
+
*
|
|
181
|
+
* Only what a browser serializes is ever echoed: one canonical http(s)
|
|
182
|
+
* origin (`new URL(origin).origin === origin`), or the opaque `null` a
|
|
183
|
+
* sandboxed or data: document sends, which Chrome matches against
|
|
184
|
+
* `access-control-allow-origin: null` and which Playwright echoes too. That
|
|
185
|
+
* refuses userinfo, a path, an explicit default port, several origins in one
|
|
186
|
+
* value, or a control character that would break the fulfill after the
|
|
187
|
+
* cardholder already approved. Anything refused simply gets no CORS answer,
|
|
188
|
+
* which is what every fulfill got before this existed.
|
|
189
|
+
*/
|
|
190
|
+
export function corsHeadersFor(url, requestHeaders) {
|
|
191
|
+
return corsDecision(url, requestHeaders).headers;
|
|
192
|
+
}
|
|
193
|
+
/** 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. */
|
|
194
|
+
export function corsDecision(url, requestHeaders) {
|
|
195
|
+
const none = { headers: null, outcome: 'none' };
|
|
196
|
+
const originEntry = Object.entries(requestHeaders ?? {}).find(([name]) => name.toLowerCase() === 'origin');
|
|
197
|
+
const origin = typeof originEntry?.[1] === 'string' ? originEntry[1].trim() : '';
|
|
198
|
+
if (!origin)
|
|
199
|
+
return none;
|
|
200
|
+
let target;
|
|
201
|
+
try {
|
|
202
|
+
target = new URL(url);
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return none;
|
|
206
|
+
}
|
|
207
|
+
if (target.protocol !== 'https:' && target.protocol !== 'http:')
|
|
208
|
+
return none;
|
|
209
|
+
if (origin !== 'null') {
|
|
210
|
+
let originUrl;
|
|
211
|
+
try {
|
|
212
|
+
originUrl = new URL(origin);
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
return none;
|
|
216
|
+
}
|
|
217
|
+
if (originUrl.protocol !== 'https:' && originUrl.protocol !== 'http:')
|
|
218
|
+
return none;
|
|
219
|
+
if (originUrl.origin !== origin)
|
|
220
|
+
return none;
|
|
221
|
+
if (target.origin === origin)
|
|
222
|
+
return { headers: null, outcome: 'same_origin' };
|
|
223
|
+
}
|
|
224
|
+
return { headers: { 'access-control-allow-origin': origin, 'access-control-allow-credentials': 'true' }, outcome: 'echoed' };
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* `headers` with the CORS answer for this request written in; the object
|
|
228
|
+
* itself when none is needed. Any header the answer names is replaced
|
|
229
|
+
* whatever its case, so a name is never sent twice. The answer varies by
|
|
230
|
+
* Origin, and a `vary` the replay already carries is extended rather than
|
|
231
|
+
* replaced (or left alone when it already covers Origin or is `*`); a `vary`
|
|
232
|
+
* the answer itself names is taken as given.
|
|
233
|
+
*/
|
|
234
|
+
export function withCorsHeaders(headers, cors) {
|
|
235
|
+
if (!cors)
|
|
236
|
+
return headers;
|
|
237
|
+
const replaced = new Set(Object.keys(cors).map((name) => name.toLowerCase()));
|
|
238
|
+
const out = {};
|
|
239
|
+
let varyName = null;
|
|
240
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
241
|
+
const lower = name.toLowerCase();
|
|
242
|
+
if (replaced.has(lower))
|
|
243
|
+
continue;
|
|
244
|
+
if (lower === 'vary')
|
|
245
|
+
varyName = name;
|
|
246
|
+
out[name] = value;
|
|
247
|
+
}
|
|
248
|
+
Object.assign(out, cors);
|
|
249
|
+
if (replaced.has('vary'))
|
|
250
|
+
return out;
|
|
251
|
+
if (varyName === null) {
|
|
252
|
+
out.vary = 'Origin';
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
const existing = String(out[varyName]);
|
|
256
|
+
const members = existing.split(',').map((m) => m.trim().toLowerCase());
|
|
257
|
+
if (!members.includes('origin') && !members.includes('*'))
|
|
258
|
+
out[varyName] = `${existing}, Origin`;
|
|
259
|
+
}
|
|
260
|
+
return out;
|
|
261
|
+
}
|
|
262
|
+
/** CDP's header shape: `{ name, value }` entries. */
|
|
263
|
+
function headerEntries(headers) {
|
|
264
|
+
return Object.entries(headers).map(([name, value]) => ({ name, value: String(value) }));
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Last-resort patterns: the built-in recognizers' hosts, derived the same way
|
|
268
|
+
* as everything else. Used only when the vault hands back nothing at all (a
|
|
269
|
+
* duck-typed client from an older SDK, or an empty registry) — a hardcoded list
|
|
270
|
+
* that drifts from the registry is exactly the bug this adapter used to have.
|
|
271
|
+
*/
|
|
272
|
+
const FALLBACK_CARD_PATTERNS = cardUrlPatterns(BUILTIN_REGISTRY);
|
|
9
273
|
/**
|
|
10
274
|
* Take over card tokenization for a page.
|
|
11
275
|
*
|
|
@@ -13,16 +277,38 @@ const CARD_PATTERNS = [
|
|
|
13
277
|
* targets. Enabling Fetch on the page session alone will never see the
|
|
14
278
|
* tokenization request. This attaches recursively so every nested target is
|
|
15
279
|
* armed, which is the whole reason this adapter exists.
|
|
280
|
+
*
|
|
281
|
+
* The patterns armed here come from the REGISTRY, not from a constant, so a PSP
|
|
282
|
+
* the API knows about is paused without an SDK release — call
|
|
283
|
+
* `vault.syncRegistry()` before attaching and every recognizer the server
|
|
284
|
+
* serves is covered. They are a coarse pre-filter and are deliberately wider
|
|
285
|
+
* than the recognizers (see cardUrlPatterns): each paused request is re-checked
|
|
286
|
+
* with `isCardRequest` below and continued untouched unless it is an exact
|
|
287
|
+
* match. Patterns are resolved once, at attach, so every nested target ends up
|
|
288
|
+
* armed identically.
|
|
16
289
|
*/
|
|
17
290
|
export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
18
291
|
const armed = new Set();
|
|
292
|
+
// Set once a failure proves that retrying cannot help; see isTerminal.
|
|
293
|
+
let terminal = null;
|
|
294
|
+
// Silence window after a person declined or ignored one; see APPROVAL_COOLDOWN_MS.
|
|
295
|
+
const cooldownMs = opts.approvalCooldownMs ?? APPROVAL_COOLDOWN_MS;
|
|
296
|
+
let quietUntil = 0;
|
|
297
|
+
// One outstanding approval at a time; see the note above isApprovalOutcome.
|
|
298
|
+
let awaitingApproval = false;
|
|
299
|
+
// The hosted form the cardholder already submitted; see HOSTED_FORM_REPEAT_QUIET_MS.
|
|
300
|
+
const repeatQuietMs = opts.hostedFormRepeatQuietMs ?? HOSTED_FORM_REPEAT_QUIET_MS;
|
|
301
|
+
let lastSubmitted = null;
|
|
302
|
+
const derived = typeof opts.vault?.cardUrlPatterns === 'function' ? opts.vault.cardUrlPatterns() : [];
|
|
303
|
+
const urlPatterns = derived.length > 0 ? derived : FALLBACK_CARD_PATTERNS;
|
|
304
|
+
opts.onEvent?.({ type: 'fetch_armed', detail: { patterns: urlPatterns } });
|
|
19
305
|
const arm = async (sessionId) => {
|
|
20
306
|
const key = sessionId ?? '__root__';
|
|
21
307
|
if (armed.has(key))
|
|
22
308
|
return;
|
|
23
309
|
armed.add(key);
|
|
24
310
|
await cdp.send('Fetch.enable', {
|
|
25
|
-
patterns:
|
|
311
|
+
patterns: urlPatterns.map((urlPattern) => ({ urlPattern, requestStage: 'Request' })),
|
|
26
312
|
}, sessionId).catch(() => { });
|
|
27
313
|
// Descend into this target's own children (iframes inside iframes).
|
|
28
314
|
await cdp.send('Target.setAutoAttach', {
|
|
@@ -39,36 +325,111 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
39
325
|
}
|
|
40
326
|
if (method !== 'Fetch.requestPaused')
|
|
41
327
|
return;
|
|
42
|
-
const { requestId, request } = params;
|
|
328
|
+
const { requestId, request, resourceType } = params;
|
|
43
329
|
if (!opts.vault.isCardRequest(request.url, request.method)) {
|
|
44
330
|
await cdp.send('Fetch.continueRequest', { requestId }, sessionId).catch(() => { });
|
|
45
331
|
return;
|
|
46
332
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
333
|
+
// Same stop condition as the Playwright adapter: once a failure proves
|
|
334
|
+
// retrying is pointless, fail the request without calling the API again.
|
|
335
|
+
if (terminal || awaitingApproval || Date.now() < quietUntil) {
|
|
336
|
+
const why = terminal ?? (awaitingApproval ? 'an approval is already outstanding' : 'awaiting approval cooldown');
|
|
337
|
+
opts.onEvent?.({ type: 'blocked', detail: String(why) });
|
|
338
|
+
await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
|
|
339
|
+
return;
|
|
50
340
|
}
|
|
51
|
-
|
|
341
|
+
// Reserve BEFORE the first await (the authorize call below yields), so two
|
|
342
|
+
// requests can never both clear the check above and raise two prompts for
|
|
343
|
+
// one checkout.
|
|
344
|
+
awaitingApproval = true;
|
|
52
345
|
try {
|
|
346
|
+
const body = pausedBody(request);
|
|
347
|
+
if (body === null) {
|
|
348
|
+
// Fail closed, but only THIS request: an unreadable body says nothing
|
|
349
|
+
// about the page's configuration, so the next request is judged
|
|
350
|
+
// afresh (no latch, no cooldown).
|
|
351
|
+
opts.onEvent?.({ type: 'failed', detail: BODY_UNREADABLE_REASON });
|
|
352
|
+
await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
// The body is needed to tell a re-post of the form the cardholder
|
|
356
|
+
// already submitted from a new one, so this check sits after the read
|
|
357
|
+
// and before any API call: no second prompt, no round trip.
|
|
358
|
+
if (isRepeatOfSubmitted(lastSubmitted, request.url, body, repeatQuietMs)) {
|
|
359
|
+
opts.onEvent?.({ type: 'blocked', detail: HOSTED_FORM_REPEAT_REASON });
|
|
360
|
+
await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
opts.onEvent?.({ type: 'card_request_paused', detail: { url: redactUrl(request.url), ...(resourceType ? { resourceType } : {}) } });
|
|
53
364
|
const replay = await opts.vault.authorize({
|
|
54
365
|
user: opts.user,
|
|
55
366
|
merchant: opts.merchant,
|
|
56
367
|
amount: opts.amount,
|
|
368
|
+
amountCents: opts.amountCents,
|
|
369
|
+
currency: opts.currency,
|
|
57
370
|
onApprovalUrl: opts.onApprovalUrl,
|
|
58
371
|
request: { url: request.url, method: request.method, headers: request.headers, body },
|
|
59
372
|
});
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
373
|
+
if (replay.mode === 'hosted_form') {
|
|
374
|
+
// The device submitted the processor's own form; the processor
|
|
375
|
+
// answered the device. The paused NAVIGATION is fulfilled with a page
|
|
376
|
+
// that says exactly that (see hosted-form.ts for why not an abort and
|
|
377
|
+
// why not a fake result page), and the same form is refused if the
|
|
378
|
+
// page posts it again.
|
|
379
|
+
const page = hostedFormSubmittedPage({ authorizationId: replay.authorizationId, merchant: opts.merchant, submittedAt: replay.submittedAt });
|
|
380
|
+
// A navigation response is never CORS-checked, so the CORS wrap is
|
|
381
|
+
// inert here; it is applied so every fulfill goes through one path.
|
|
382
|
+
await cdp.send('Fetch.fulfillRequest', {
|
|
383
|
+
requestId,
|
|
384
|
+
responseCode: page.status,
|
|
385
|
+
responseHeaders: headerEntries(withCorsHeaders(page.headers, corsHeadersFor(request.url, request.headers))),
|
|
386
|
+
body: Buffer.from(page.body).toString('base64'),
|
|
387
|
+
}, sessionId);
|
|
388
|
+
lastSubmitted = { url: request.url, body, at: Date.now() };
|
|
389
|
+
// Named for what it is: a device-attested submission with no
|
|
390
|
+
// processor evidence, never an `authorized` event.
|
|
391
|
+
opts.onEvent?.({ type: 'submitted_on_device', detail: { authorizationId: replay.authorizationId, submittedAt: replay.submittedAt, outcome: replay.outcome } });
|
|
392
|
+
}
|
|
393
|
+
else if (replay.mode === 'cse') {
|
|
394
|
+
// The device encrypted the card for the processor; the request itself
|
|
395
|
+
// still goes out from THIS browser, with its own session, risk data
|
|
396
|
+
// and cookies, and only the four ciphertext fields swapped in. Only
|
|
397
|
+
// postData rides on the command: no header override, ever (see
|
|
398
|
+
// cseBody for why a recomputed Content-Length is refused by Chromium).
|
|
399
|
+
await cdp.send('Fetch.continueRequest', {
|
|
400
|
+
requestId,
|
|
401
|
+
postData: Buffer.from(cseBody(body, replay)).toString('base64'),
|
|
402
|
+
}, sessionId);
|
|
403
|
+
opts.onEvent?.({ type: 'authorized', detail: { mode: 'cse', authorizationId: replay.authorizationId, fields: Object.keys(replay.substitutions.fields) } });
|
|
404
|
+
}
|
|
405
|
+
else {
|
|
406
|
+
// The processor's answer, handed to the page as if the processor had
|
|
407
|
+
// sent it. Cross-origin, the browser then runs its CORS check on it:
|
|
408
|
+
// see corsHeadersFor for why the answer has to carry the headers. The
|
|
409
|
+
// decision rides on the event, so a fulfill the page could not read
|
|
410
|
+
// (no usable Origin on a cross-origin request) is visible in telemetry
|
|
411
|
+
// rather than only as the page's own "connection error".
|
|
412
|
+
const cors = corsDecision(request.url, request.headers);
|
|
413
|
+
await cdp.send('Fetch.fulfillRequest', {
|
|
414
|
+
requestId,
|
|
415
|
+
responseCode: replay.status,
|
|
416
|
+
responseHeaders: headerEntries(withCorsHeaders(replay.headers, cors.headers)),
|
|
417
|
+
body: Buffer.from(replay.body).toString('base64'),
|
|
418
|
+
}, sessionId);
|
|
419
|
+
opts.onEvent?.({ type: 'authorized', detail: { mode: 'token', authorizationId: replay.authorizationId, amountVerified: replay.amountVerified ?? null, cors: cors.outcome } });
|
|
420
|
+
}
|
|
67
421
|
}
|
|
68
422
|
catch (err) {
|
|
423
|
+
if (isTerminal(err))
|
|
424
|
+
terminal = err;
|
|
425
|
+
else if (isApprovalOutcome(err))
|
|
426
|
+
quietUntil = Date.now() + cooldownMs;
|
|
69
427
|
opts.onEvent?.({ type: 'failed', detail: String(err) });
|
|
70
428
|
await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
|
|
71
429
|
}
|
|
430
|
+
finally {
|
|
431
|
+
awaitingApproval = false;
|
|
432
|
+
}
|
|
72
433
|
});
|
|
73
434
|
await arm(pageSessionId);
|
|
74
435
|
}
|
|
@@ -95,6 +456,17 @@ export async function attachToPlaywright(page, opts) {
|
|
|
95
456
|
//
|
|
96
457
|
// page.route already spans subframes, so Playwright does the target
|
|
97
458
|
// bookkeeping that attachToCdp has to do by hand for a raw connection.
|
|
459
|
+
// Set once a failure proves that retrying cannot help. The page is free to
|
|
460
|
+
// keep retrying; we simply stop asking the API and fail the request outright.
|
|
461
|
+
let terminal = null;
|
|
462
|
+
// Silence window after a person declined or ignored one; see APPROVAL_COOLDOWN_MS.
|
|
463
|
+
const cooldownMs = opts.approvalCooldownMs ?? APPROVAL_COOLDOWN_MS;
|
|
464
|
+
let quietUntil = 0;
|
|
465
|
+
// One outstanding approval at a time; see the note above isApprovalOutcome.
|
|
466
|
+
let awaitingApproval = false;
|
|
467
|
+
// The hosted form the cardholder already submitted; see HOSTED_FORM_REPEAT_QUIET_MS.
|
|
468
|
+
const repeatQuietMs = opts.hostedFormRepeatQuietMs ?? HOSTED_FORM_REPEAT_QUIET_MS;
|
|
469
|
+
let lastSubmitted = null;
|
|
98
470
|
await page.route((url) => opts.vault.isCardRequest(url.toString()), async (route) => {
|
|
99
471
|
const request = route.request();
|
|
100
472
|
// The matcher only sees the URL; a preflight or a GET must pass through
|
|
@@ -102,22 +474,69 @@ export async function attachToPlaywright(page, opts) {
|
|
|
102
474
|
if (!opts.vault.isCardRequest(request.url(), request.method())) {
|
|
103
475
|
return route.fallback();
|
|
104
476
|
}
|
|
105
|
-
|
|
106
|
-
|
|
477
|
+
// Fail closed and stay quiet: no card may reach the PSP, but neither may
|
|
478
|
+
// the page's retry loop turn into a stream of doomed API calls. Every
|
|
479
|
+
// abort in this adapter is 'aborted' (ERR_ABORTED), the same code the
|
|
480
|
+
// CDP adapter's Fetch.failRequest uses, so a refused navigation
|
|
481
|
+
// resolves identically whichever adapter is attached.
|
|
482
|
+
if (terminal || awaitingApproval || Date.now() < quietUntil) {
|
|
483
|
+
const why = terminal ?? (awaitingApproval ? 'an approval is already outstanding' : 'awaiting approval cooldown');
|
|
484
|
+
opts.onEvent?.({ type: 'blocked', detail: String(why) });
|
|
485
|
+
return route.abort('aborted');
|
|
486
|
+
}
|
|
487
|
+
// Reserved before anything that could yield, matching attachToCdp.
|
|
488
|
+
awaitingApproval = true;
|
|
107
489
|
try {
|
|
490
|
+
const body = request.postData() ?? '';
|
|
491
|
+
if (isRepeatOfSubmitted(lastSubmitted, request.url(), body, repeatQuietMs)) {
|
|
492
|
+
opts.onEvent?.({ type: 'blocked', detail: HOSTED_FORM_REPEAT_REASON });
|
|
493
|
+
return await route.abort('aborted');
|
|
494
|
+
}
|
|
495
|
+
opts.onEvent?.({ type: 'card_request_paused', detail: { url: redactUrl(request.url()) } });
|
|
108
496
|
const replay = await opts.vault.authorize({
|
|
109
497
|
user: opts.user,
|
|
110
498
|
merchant: opts.merchant,
|
|
111
499
|
amount: opts.amount,
|
|
500
|
+
amountCents: opts.amountCents,
|
|
501
|
+
currency: opts.currency,
|
|
112
502
|
onApprovalUrl: opts.onApprovalUrl,
|
|
113
503
|
request: { url: request.url(), method: request.method(), headers: request.headers(), body },
|
|
114
504
|
});
|
|
115
|
-
|
|
116
|
-
|
|
505
|
+
if (replay.mode === 'hosted_form') {
|
|
506
|
+
// Same as the CDP path: the paused navigation resolves to the
|
|
507
|
+
// synthetic page, and a re-post of this form is refused.
|
|
508
|
+
const synthetic = hostedFormSubmittedPage({ authorizationId: replay.authorizationId, merchant: opts.merchant, submittedAt: replay.submittedAt });
|
|
509
|
+
// Inert on a navigation (never CORS-checked); one path for every fulfill.
|
|
510
|
+
await route.fulfill({ status: synthetic.status, headers: withCorsHeaders(synthetic.headers, corsHeadersFor(request.url(), request.headers())), body: synthetic.body });
|
|
511
|
+
lastSubmitted = { url: request.url(), body, at: Date.now() };
|
|
512
|
+
opts.onEvent?.({ type: 'submitted_on_device', detail: { authorizationId: replay.authorizationId, submittedAt: replay.submittedAt, outcome: replay.outcome } });
|
|
513
|
+
}
|
|
514
|
+
else if (replay.mode === 'cse') {
|
|
515
|
+
// Same as the CDP path: the request continues from this browser
|
|
516
|
+
// with the ciphertext swapped in and no header override; Playwright
|
|
517
|
+
// recomputes the length itself.
|
|
518
|
+
await route.continue({ postData: cseBody(body, replay) });
|
|
519
|
+
opts.onEvent?.({ type: 'authorized', detail: { mode: 'cse', authorizationId: replay.authorizationId, fields: Object.keys(replay.substitutions.fields) } });
|
|
520
|
+
}
|
|
521
|
+
else {
|
|
522
|
+
// Playwright adds these itself when a cross-origin fulfill carries
|
|
523
|
+
// none; written here anyway (replacing a stale value) so a
|
|
524
|
+
// cross-origin answer is the same whichever adapter ran.
|
|
525
|
+
const cors = corsDecision(request.url(), request.headers());
|
|
526
|
+
await route.fulfill({ status: replay.status, headers: withCorsHeaders(replay.headers, cors.headers), body: replay.body });
|
|
527
|
+
opts.onEvent?.({ type: 'authorized', detail: { mode: 'token', authorizationId: replay.authorizationId, amountVerified: replay.amountVerified ?? null, cors: cors.outcome } });
|
|
528
|
+
}
|
|
117
529
|
}
|
|
118
530
|
catch (err) {
|
|
531
|
+
if (isTerminal(err))
|
|
532
|
+
terminal = err;
|
|
533
|
+
else if (isApprovalOutcome(err))
|
|
534
|
+
quietUntil = Date.now() + cooldownMs;
|
|
119
535
|
opts.onEvent?.({ type: 'failed', detail: String(err) });
|
|
120
|
-
await route.abort();
|
|
536
|
+
await route.abort('aborted');
|
|
537
|
+
}
|
|
538
|
+
finally {
|
|
539
|
+
awaitingApproval = false;
|
|
121
540
|
}
|
|
122
541
|
});
|
|
123
542
|
}
|