@agent-cards/checkout 0.1.0 → 0.2.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/README.md +160 -6
- package/dist/cdp.d.ts +24 -2
- package/dist/cdp.js +309 -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 +8 -4
- package/dist/index.js +4 -2
- 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/README.md
CHANGED
|
@@ -42,15 +42,40 @@ const vault = new VaultClient({
|
|
|
42
42
|
clientSecret: process.env.AGENTCARD_CLIENT_SECRET!,
|
|
43
43
|
});
|
|
44
44
|
|
|
45
|
+
// Pull the current processor list. attachToCdp arms the browser from it, so
|
|
46
|
+
// without this you only intercept the processors built into your installed
|
|
47
|
+
// version. Safe to call on every run: a failed fetch keeps the built-ins.
|
|
48
|
+
await vault.syncRegistry();
|
|
49
|
+
|
|
45
50
|
await attachToCdp(cdp, pageSessionId, {
|
|
46
51
|
vault,
|
|
47
52
|
user: 'usr_123', // whose card should pay
|
|
48
53
|
merchant: 'vanman.shop',
|
|
49
|
-
|
|
54
|
+
amountCents: 583, // what the user approves, smallest currency unit
|
|
55
|
+
currency: 'usd', // "$5.83" is derived for the approval screen
|
|
50
56
|
onApprovalUrl: (url) => sendToUser(url), // iMessage, SMS, push, your call
|
|
51
57
|
});
|
|
52
58
|
```
|
|
53
59
|
|
|
60
|
+
`amount: '$5.83'` (a display string) still works on its own. Pass
|
|
61
|
+
`amountCents` + `currency` when you want the amount enforced: on a Stripe
|
|
62
|
+
PaymentIntent confirm, Agentcard reads the intent back from Stripe at create
|
|
63
|
+
and again right before the cardholder's device replays, a different amount is
|
|
64
|
+
refused with nothing charged, and after the replay the charge is reconciled
|
|
65
|
+
against the approval (`ReplayResponse.amountVerified`, with
|
|
66
|
+
`chargedAmountCents` and `chargedKind`: `captured` for a succeeded intent's
|
|
67
|
+
`amount_received`, `authorized` for a manual-capture intent's
|
|
68
|
+
`amount_capturable`, `none` when nothing is collected yet; plus the
|
|
69
|
+
`checkout_authorization.amount_mismatch` webhook to your server when it
|
|
70
|
+
disagrees). Amounts are Stripe minor units up to 2^53-1. `onEvent` payloads
|
|
71
|
+
name URLs by origin and path only, so a client secret in a paused request's
|
|
72
|
+
query string never reaches your telemetry. When you pass the pair, any
|
|
73
|
+
`amount` string you also pass is
|
|
74
|
+
ignored: Agentcard derives the display string from the number, following
|
|
75
|
+
Stripe's minor units, so the approval screen, the notifications and every
|
|
76
|
+
read show one amount. Tokenization requests carry no amount, so there the
|
|
77
|
+
pair is shown and reported (`amountAuthority: 'display_only'`), not enforced.
|
|
78
|
+
|
|
54
79
|
Then let your agent click "Pay" like it always does. `attachToCdp` holds the
|
|
55
80
|
request open until the cardholder approves, so the checkout simply continues.
|
|
56
81
|
|
|
@@ -101,18 +126,147 @@ Merchants inherit their processor, so one entry covers every store on it.
|
|
|
101
126
|
| Stripe | supported, verified end to end |
|
|
102
127
|
| Braintree / PayPal | supported, verified end to end |
|
|
103
128
|
| Checkout.com | supported |
|
|
104
|
-
| Adyen |
|
|
129
|
+
| Adyen | supported (mode `cse`): the vault encrypts the card for Adyen on the cardholder's device and your browser sends it |
|
|
130
|
+
| Tranzila | supported (mode `hosted_form`): the cardholder finishes on Tranzila's own page; the paused form navigation resolves to a synthetic page, and you poll the merchant's order state |
|
|
105
131
|
|
|
106
132
|
The recognizer list is fetched from the API at runtime (`vault.syncRegistry()`),
|
|
107
|
-
so new processors work without you shipping a release.
|
|
133
|
+
so new processors work without you shipping a release. `attachToCdp` derives the
|
|
134
|
+
`Fetch.enable` url patterns from that same list rather than a constant, which is
|
|
135
|
+
why the sync call belongs before the attach. `vault.cardUrlPatterns()` returns
|
|
136
|
+
those patterns if you arm a CDP connection yourself. Call
|
|
137
|
+
`GET /v2/checkout/recognizers?modes=token,cse,hosted_form` for the list that is
|
|
138
|
+
live right now.
|
|
139
|
+
|
|
140
|
+
## Modes
|
|
141
|
+
|
|
142
|
+
Each recognizer carries a `mode` (absent means `token`), and every authorization
|
|
143
|
+
carries the mode it was handled in. The adapters do the right thing for all
|
|
144
|
+
three; the difference matters if you drive `authorize()` yourself.
|
|
145
|
+
`ReplayResponse` is a union, so branch on `mode`.
|
|
146
|
+
|
|
147
|
+
- **`token`** (every processor but Adyen). The cardholder's device calls the
|
|
148
|
+
processor and reports its answer; `authorize()` resolves with `status`,
|
|
149
|
+
`headers` and `body` to fulfill the paused request with.
|
|
150
|
+
- **`cse`** (Adyen). Adyen's own page SDK encrypts the card before the request
|
|
151
|
+
leaves the browser, so the paused body carries ciphertext. The cardholder's
|
|
152
|
+
device produces the same ciphertext under the merchant's Adyen public key
|
|
153
|
+
(fetched by Agentcard from Adyen's host when the request is parked) and
|
|
154
|
+
`authorize()` resolves with `substitutions: { encoding: 'json', at, fields,
|
|
155
|
+
remove }`. Write them into the paused body with
|
|
156
|
+
`substituteEncryptedFields(body, substitutions)` and CONTINUE the request
|
|
157
|
+
from the same browser (`Fetch.continueRequest` with the rewritten
|
|
158
|
+
`postData`, or Playwright's `route.continue({ postData })`): its session
|
|
159
|
+
data, risk data and cookies must stay its own. Only the four encrypted
|
|
160
|
+
fields change, and the siblings named in `remove` are dropped (Adyen's
|
|
161
|
+
`brand`, which adyen-web derived from the dummy digits the agent typed:
|
|
162
|
+
left in place it names the wrong card and Adyen refuses the mismatch;
|
|
163
|
+
absent, Adyen reads the brand off the card it decrypts). A body that lacks
|
|
164
|
+
the fields throws `SubstitutionError`, which is not terminal. Adyen answers the browser,
|
|
165
|
+
so `charged_kind` is null on the approval and the merchant's order state is
|
|
166
|
+
the outcome to poll.
|
|
167
|
+
- **`hosted_form`** (Tranzila). The processor's hosted card form submits the
|
|
168
|
+
card as a TOP-LEVEL form post, so the paused request is a page navigation
|
|
169
|
+
(the adapters arm `Fetch.enable` with no resource-type filter and attach the
|
|
170
|
+
processor's iframe, which is how a Document request on `direct.tranzila.com`
|
|
171
|
+
gets paused at all). The cardholder's device rebuilds that form with the
|
|
172
|
+
real card and submits it itself; the processor answers the device, and
|
|
173
|
+
`authorize()` resolves with `{ mode: 'hosted_form', kind:
|
|
174
|
+
'submitted_on_device', outcome: 'unverified', submittedAt }` once the
|
|
175
|
+
device reports the form left. **This is not an approved payment.** The
|
|
176
|
+
stamp is the cardholder's device attesting that the form left it;
|
|
177
|
+
Agentcard holds no processor evidence on this mode and cannot obtain any,
|
|
178
|
+
so the API finishes the authorization as `submitted_on_device` (never
|
|
179
|
+
`approved`) and sends your server `checkout_authorization.submitted`
|
|
180
|
+
(never `.approved`). Treat it as "the person paid, or tried to, on their
|
|
181
|
+
own device" and confirm the order with the merchant before you count it.
|
|
182
|
+
There is no response to replay: FULFIL the paused navigation with
|
|
183
|
+
`hostedFormSubmittedPage({ authorizationId, merchant, submittedAt })` (200,
|
|
184
|
+
`text/html`, `x-agentcard-checkout: submitted_on_device`, a `<meta
|
|
185
|
+
name="agentcard-checkout">` and an inert JSON block saying "submitted on
|
|
186
|
+
the cardholder's device, payment unverified, do not resubmit"), the way
|
|
187
|
+
the adapters do. Do not abort it: an aborted navigation renders nothing,
|
|
188
|
+
the iframe silently keeps its dummy-card form, and the agent's next move is
|
|
189
|
+
to click Pay again. Do not fake the processor's result page either: this
|
|
190
|
+
SDK does not know the outcome. The adapters emit `submitted_on_device` (not
|
|
191
|
+
`authorized`), refuse a byte-identical re-post of the same form for 15
|
|
192
|
+
minutes (`hostedFormRepeatQuietMs`), and the API answers a regenerated one
|
|
193
|
+
with `409 duplicate_submission`, which the adapters quiet the way they quiet
|
|
194
|
+
a decline (`approvalCooldownMs`): the page's immediate re-posts are refused
|
|
195
|
+
without a round trip, and once the prior authorization is declined or
|
|
196
|
+
expired the same form is a new question. Confirm the order with the
|
|
197
|
+
merchant, which learns the outcome from the processor.
|
|
198
|
+
|
|
199
|
+
`syncRegistry()` asks the API for `SUPPORTED_MODES` only
|
|
200
|
+
(`token,cse,hosted_form`), so a processor whose flow this build cannot finish
|
|
201
|
+
is never paused; the API serves `hosted_form` entries only to callers that ask.
|
|
202
|
+
An approval in some other mode throws `UnsupportedModeError`, which is
|
|
203
|
+
terminal for that page (upgrade the SDK). The `authorized` event's detail names
|
|
204
|
+
the `mode`, the `authorizationId` and, for `cse`, the `fields` that were
|
|
205
|
+
substituted; it never carries ciphertext. The `submitted_on_device` event's
|
|
206
|
+
detail names the `authorizationId`, `submittedAt` and `outcome:
|
|
207
|
+
'unverified'`; it is not an `authorized` event and must not be counted as
|
|
208
|
+
one. `amountAuthority` on every replay is `stripe_payment_intent`,
|
|
209
|
+
`hosted_form_sum` (the form's own amount) or `display_only`.
|
|
108
210
|
|
|
109
211
|
## Errors worth handling
|
|
110
212
|
|
|
111
213
|
- `ApprovalTimeoutError` — the user never approved. Default window is 15 minutes;
|
|
112
214
|
we have completed checkouts after a 5.5 minute approval delay.
|
|
113
215
|
- `ApprovalDeclinedError` — the user said no.
|
|
114
|
-
- `
|
|
115
|
-
|
|
216
|
+
- `AmountMismatchError`: the processor's amount did not match the amount the
|
|
217
|
+
user was (or would have been) asked to approve. Nothing was charged. An
|
|
218
|
+
`ApprovalDeclinedError` with `expectedCents`, `actualCents`, `currency`,
|
|
219
|
+
`code: 'amount_mismatch'` and `stage`: `'pre_replay'` (checked right before
|
|
220
|
+
the device would have sent the card; `authorizationId` names the declined
|
|
221
|
+
authorization) or `'create'` (the intent already disagreed when the request
|
|
222
|
+
was parked; no authorization exists, `authorizationId` is null). Per
|
|
223
|
+
request, not per page: a merchant can still update an intent's amount
|
|
224
|
+
until it is confirmed, so the adapters quiet the page's immediate retry
|
|
225
|
+
and judge the next request afresh instead of latching.
|
|
226
|
+
- `IntentNotConfirmableError`: the PaymentIntent was already charged, is
|
|
227
|
+
processing, or is authorized and on hold (or canceled), so Agentcard
|
|
228
|
+
refused to replay a confirm at it. An `ApprovalDeclinedError` with
|
|
229
|
+
`code: 'intent_not_confirmable'`. Deliberately not "nothing was charged":
|
|
230
|
+
check the intent at Stripe before retrying.
|
|
231
|
+
- `ProcessorRefusedError`: the cardholder's device sent the card and the
|
|
232
|
+
processor refused it outright (`pspErrorCode` is the processor's own code,
|
|
233
|
+
such as Stripe's `card_declined`). Nothing was charged. An
|
|
234
|
+
`ApprovalDeclinedError` with `code: 'processor_refused'`; the page's next
|
|
235
|
+
attempt raises a fresh approval where the person can pick another card.
|
|
236
|
+
- `CheckoutApiError` with `code === 'amount_unverifiable'`: Stripe could not
|
|
237
|
+
be asked (502; the SDK retries twice, 500ms then 1500ms, before throwing)
|
|
238
|
+
or the paused request lacked its client secret or publishable key (400).
|
|
239
|
+
`code === 'intent_not_confirmable'` at create (409) means the intent was
|
|
240
|
+
already used; the adapters stop intercepting for that page.
|
|
241
|
+
`code === 'cse_key_unavailable'` (502) means Adyen did not answer the
|
|
242
|
+
public-key fetch and is retried the same way; `cse_client_key_unknown`
|
|
243
|
+
(400) means Adyen does not know the merchant's `clientKey`, and
|
|
244
|
+
`cse_template_unsupported` (400) means the paused body carries no
|
|
245
|
+
encrypted card fields to fill (a stored card, a wallet, a single-blob
|
|
246
|
+
`encryptedCard`); both are terminal for that page.
|
|
247
|
+
`hosted_form_template_incomplete` (400) means the paused form lacks a
|
|
248
|
+
field the device fills or the processor requires, `hosted_form_gated`
|
|
249
|
+
(400) means it carries a live captcha token the device could never
|
|
250
|
+
re-submit, and `hosted_form_field_refused` (400, with `field` and a
|
|
251
|
+
`reason` of `stored_credential`, `not_a_sale` or `callback_host`) means
|
|
252
|
+
the form asks the processor for something other than one plain sale
|
|
253
|
+
reporting to the merchant you named (a reusable token in or out, a sale
|
|
254
|
+
mode that is not a sale, a callback URL off the merchant's host: name the
|
|
255
|
+
merchant by its hostname when the form carries callback URLs); all three
|
|
256
|
+
are terminal for that page. `duplicate_submission` (409,
|
|
257
|
+
with `prior_authorization_id` and `prior_status`) means this exact
|
|
258
|
+
submission already has, or already had, its prompt: the adapters quiet the
|
|
259
|
+
page's re-posts for `approvalCooldownMs` (no second notification for one
|
|
260
|
+
payment) and judge the next request afresh, since the prior authorization
|
|
261
|
+
declines or expires and the same form is then a new question.
|
|
262
|
+
- `CardEncryptedError`: this processor encrypts the card in-page and its
|
|
263
|
+
registry entry does not (yet) say the vault can produce that ciphertext;
|
|
264
|
+
route the purchase to an Agentcard-issued card instead.
|
|
265
|
+
- `UnsupportedModeError`: the approval came back in a mode this build of the
|
|
266
|
+
SDK cannot finish. Terminal for the page; upgrade.
|
|
267
|
+
- `SubstitutionError`: a `cse` approval could not be written into the paused
|
|
268
|
+
body (the four encrypted fields were not there). The request is failed and
|
|
269
|
+
the next one is judged afresh.
|
|
116
270
|
|
|
117
271
|
## Building this package
|
|
118
272
|
|
|
@@ -122,5 +276,5 @@ be installed here without regenerating the lockfile, and a full regen drifts
|
|
|
122
276
|
unrelated transitive versions). Build it with the workspace TypeScript:
|
|
123
277
|
|
|
124
278
|
```bash
|
|
125
|
-
|
|
279
|
+
cd packages/checkout && pnpm build && pnpm test
|
|
126
280
|
```
|
package/dist/cdp.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type VaultClient } from './client.js';
|
|
2
2
|
/**
|
|
3
3
|
* Minimal shape of a CDP connection. Works with a raw websocket client, a
|
|
4
4
|
* Puppeteer CDPSession, or Playwright's CDPSession.
|
|
@@ -11,12 +11,25 @@ export interface AttachOptions {
|
|
|
11
11
|
vault: VaultClient;
|
|
12
12
|
user: string;
|
|
13
13
|
merchant: string;
|
|
14
|
-
|
|
14
|
+
/** Display string for the approval screen. Optional when amountCents + currency are given. */
|
|
15
|
+
amount?: string;
|
|
16
|
+
/**
|
|
17
|
+
* The amount as a number (smallest currency unit) with its ISO 4217 code.
|
|
18
|
+
* See AuthorizeInput.amountCents: on a Stripe PaymentIntent confirm this
|
|
19
|
+
* binds the approval to the intent's amount, checked at create and again
|
|
20
|
+
* right before replay, so a larger charge is refused with nothing charged.
|
|
21
|
+
*/
|
|
22
|
+
amountCents?: number;
|
|
23
|
+
currency?: string;
|
|
15
24
|
onApprovalUrl?: (url: string) => void;
|
|
16
25
|
onEvent?: (e: {
|
|
17
26
|
type: string;
|
|
18
27
|
detail?: unknown;
|
|
19
28
|
}) => void;
|
|
29
|
+
/** Silence after a decline or timeout. Defaults to APPROVAL_COOLDOWN_MS. */
|
|
30
|
+
approvalCooldownMs?: number;
|
|
31
|
+
/** How long a hosted form the device already submitted stays refused on a re-post. Defaults to HOSTED_FORM_REPEAT_QUIET_MS. */
|
|
32
|
+
hostedFormRepeatQuietMs?: number;
|
|
20
33
|
}
|
|
21
34
|
/**
|
|
22
35
|
* Take over card tokenization for a page.
|
|
@@ -25,6 +38,15 @@ export interface AttachOptions {
|
|
|
25
38
|
* targets. Enabling Fetch on the page session alone will never see the
|
|
26
39
|
* tokenization request. This attaches recursively so every nested target is
|
|
27
40
|
* armed, which is the whole reason this adapter exists.
|
|
41
|
+
*
|
|
42
|
+
* The patterns armed here come from the REGISTRY, not from a constant, so a PSP
|
|
43
|
+
* the API knows about is paused without an SDK release — call
|
|
44
|
+
* `vault.syncRegistry()` before attaching and every recognizer the server
|
|
45
|
+
* serves is covered. They are a coarse pre-filter and are deliberately wider
|
|
46
|
+
* than the recognizers (see cardUrlPatterns): each paused request is re-checked
|
|
47
|
+
* with `isCardRequest` below and continued untouched unless it is an exact
|
|
48
|
+
* match. Patterns are resolved once, at attach, so every nested target ends up
|
|
49
|
+
* armed identically.
|
|
28
50
|
*/
|
|
29
51
|
export declare function attachToCdp(cdp: CdpLike, pageSessionId: string, opts: AttachOptions): Promise<void>;
|
|
30
52
|
/**
|
package/dist/cdp.js
CHANGED
|
@@ -1,11 +1,153 @@
|
|
|
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
|
+
* Last-resort patterns: the built-in recognizers' hosts, derived the same way
|
|
146
|
+
* as everything else. Used only when the vault hands back nothing at all (a
|
|
147
|
+
* duck-typed client from an older SDK, or an empty registry) — a hardcoded list
|
|
148
|
+
* that drifts from the registry is exactly the bug this adapter used to have.
|
|
149
|
+
*/
|
|
150
|
+
const FALLBACK_CARD_PATTERNS = cardUrlPatterns(BUILTIN_REGISTRY);
|
|
9
151
|
/**
|
|
10
152
|
* Take over card tokenization for a page.
|
|
11
153
|
*
|
|
@@ -13,16 +155,38 @@ const CARD_PATTERNS = [
|
|
|
13
155
|
* targets. Enabling Fetch on the page session alone will never see the
|
|
14
156
|
* tokenization request. This attaches recursively so every nested target is
|
|
15
157
|
* armed, which is the whole reason this adapter exists.
|
|
158
|
+
*
|
|
159
|
+
* The patterns armed here come from the REGISTRY, not from a constant, so a PSP
|
|
160
|
+
* the API knows about is paused without an SDK release — call
|
|
161
|
+
* `vault.syncRegistry()` before attaching and every recognizer the server
|
|
162
|
+
* serves is covered. They are a coarse pre-filter and are deliberately wider
|
|
163
|
+
* than the recognizers (see cardUrlPatterns): each paused request is re-checked
|
|
164
|
+
* with `isCardRequest` below and continued untouched unless it is an exact
|
|
165
|
+
* match. Patterns are resolved once, at attach, so every nested target ends up
|
|
166
|
+
* armed identically.
|
|
16
167
|
*/
|
|
17
168
|
export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
18
169
|
const armed = new Set();
|
|
170
|
+
// Set once a failure proves that retrying cannot help; see isTerminal.
|
|
171
|
+
let terminal = null;
|
|
172
|
+
// Silence window after a person declined or ignored one; see APPROVAL_COOLDOWN_MS.
|
|
173
|
+
const cooldownMs = opts.approvalCooldownMs ?? APPROVAL_COOLDOWN_MS;
|
|
174
|
+
let quietUntil = 0;
|
|
175
|
+
// One outstanding approval at a time; see the note above isApprovalOutcome.
|
|
176
|
+
let awaitingApproval = false;
|
|
177
|
+
// The hosted form the cardholder already submitted; see HOSTED_FORM_REPEAT_QUIET_MS.
|
|
178
|
+
const repeatQuietMs = opts.hostedFormRepeatQuietMs ?? HOSTED_FORM_REPEAT_QUIET_MS;
|
|
179
|
+
let lastSubmitted = null;
|
|
180
|
+
const derived = typeof opts.vault?.cardUrlPatterns === 'function' ? opts.vault.cardUrlPatterns() : [];
|
|
181
|
+
const urlPatterns = derived.length > 0 ? derived : FALLBACK_CARD_PATTERNS;
|
|
182
|
+
opts.onEvent?.({ type: 'fetch_armed', detail: { patterns: urlPatterns } });
|
|
19
183
|
const arm = async (sessionId) => {
|
|
20
184
|
const key = sessionId ?? '__root__';
|
|
21
185
|
if (armed.has(key))
|
|
22
186
|
return;
|
|
23
187
|
armed.add(key);
|
|
24
188
|
await cdp.send('Fetch.enable', {
|
|
25
|
-
patterns:
|
|
189
|
+
patterns: urlPatterns.map((urlPattern) => ({ urlPattern, requestStage: 'Request' })),
|
|
26
190
|
}, sessionId).catch(() => { });
|
|
27
191
|
// Descend into this target's own children (iframes inside iframes).
|
|
28
192
|
await cdp.send('Target.setAutoAttach', {
|
|
@@ -39,36 +203,102 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
39
203
|
}
|
|
40
204
|
if (method !== 'Fetch.requestPaused')
|
|
41
205
|
return;
|
|
42
|
-
const { requestId, request } = params;
|
|
206
|
+
const { requestId, request, resourceType } = params;
|
|
43
207
|
if (!opts.vault.isCardRequest(request.url, request.method)) {
|
|
44
208
|
await cdp.send('Fetch.continueRequest', { requestId }, sessionId).catch(() => { });
|
|
45
209
|
return;
|
|
46
210
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
211
|
+
// Same stop condition as the Playwright adapter: once a failure proves
|
|
212
|
+
// 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) });
|
|
216
|
+
await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
|
|
217
|
+
return;
|
|
50
218
|
}
|
|
51
|
-
|
|
219
|
+
// Reserve BEFORE the first await (the authorize call below yields), so two
|
|
220
|
+
// requests can never both clear the check above and raise two prompts for
|
|
221
|
+
// one checkout.
|
|
222
|
+
awaitingApproval = true;
|
|
52
223
|
try {
|
|
224
|
+
const body = pausedBody(request);
|
|
225
|
+
if (body === null) {
|
|
226
|
+
// Fail closed, but only THIS request: an unreadable body says nothing
|
|
227
|
+
// about the page's configuration, so the next request is judged
|
|
228
|
+
// afresh (no latch, no cooldown).
|
|
229
|
+
opts.onEvent?.({ type: 'failed', detail: BODY_UNREADABLE_REASON });
|
|
230
|
+
await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
// The body is needed to tell a re-post of the form the cardholder
|
|
234
|
+
// already submitted from a new one, so this check sits after the read
|
|
235
|
+
// and before any API call: no second prompt, no round trip.
|
|
236
|
+
if (isRepeatOfSubmitted(lastSubmitted, request.url, body, repeatQuietMs)) {
|
|
237
|
+
opts.onEvent?.({ type: 'blocked', detail: HOSTED_FORM_REPEAT_REASON });
|
|
238
|
+
await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
opts.onEvent?.({ type: 'card_request_paused', detail: { url: redactUrl(request.url), ...(resourceType ? { resourceType } : {}) } });
|
|
53
242
|
const replay = await opts.vault.authorize({
|
|
54
243
|
user: opts.user,
|
|
55
244
|
merchant: opts.merchant,
|
|
56
245
|
amount: opts.amount,
|
|
246
|
+
amountCents: opts.amountCents,
|
|
247
|
+
currency: opts.currency,
|
|
57
248
|
onApprovalUrl: opts.onApprovalUrl,
|
|
58
249
|
request: { url: request.url, method: request.method, headers: request.headers, body },
|
|
59
250
|
});
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
251
|
+
if (replay.mode === 'hosted_form') {
|
|
252
|
+
// The device submitted the processor's own form; the processor
|
|
253
|
+
// answered the device. The paused NAVIGATION is fulfilled with a page
|
|
254
|
+
// that says exactly that (see hosted-form.ts for why not an abort and
|
|
255
|
+
// why not a fake result page), and the same form is refused if the
|
|
256
|
+
// page posts it again.
|
|
257
|
+
const page = hostedFormSubmittedPage({ authorizationId: replay.authorizationId, merchant: opts.merchant, submittedAt: replay.submittedAt });
|
|
258
|
+
await cdp.send('Fetch.fulfillRequest', {
|
|
259
|
+
requestId,
|
|
260
|
+
responseCode: page.status,
|
|
261
|
+
responseHeaders: Object.entries(page.headers).map(([name, value]) => ({ name, value })),
|
|
262
|
+
body: Buffer.from(page.body).toString('base64'),
|
|
263
|
+
}, sessionId);
|
|
264
|
+
lastSubmitted = { url: request.url, body, at: Date.now() };
|
|
265
|
+
// Named for what it is: a device-attested submission with no
|
|
266
|
+
// processor evidence, never an `authorized` event.
|
|
267
|
+
opts.onEvent?.({ type: 'submitted_on_device', detail: { authorizationId: replay.authorizationId, submittedAt: replay.submittedAt, outcome: replay.outcome } });
|
|
268
|
+
}
|
|
269
|
+
else if (replay.mode === 'cse') {
|
|
270
|
+
// The device encrypted the card for the processor; the request itself
|
|
271
|
+
// still goes out from THIS browser, with its own session, risk data
|
|
272
|
+
// and cookies, and only the four ciphertext fields swapped in. Only
|
|
273
|
+
// postData rides on the command: no header override, ever (see
|
|
274
|
+
// cseBody for why a recomputed Content-Length is refused by Chromium).
|
|
275
|
+
await cdp.send('Fetch.continueRequest', {
|
|
276
|
+
requestId,
|
|
277
|
+
postData: Buffer.from(cseBody(body, replay)).toString('base64'),
|
|
278
|
+
}, sessionId);
|
|
279
|
+
opts.onEvent?.({ type: 'authorized', detail: { mode: 'cse', authorizationId: replay.authorizationId, fields: Object.keys(replay.substitutions.fields) } });
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
await cdp.send('Fetch.fulfillRequest', {
|
|
283
|
+
requestId,
|
|
284
|
+
responseCode: replay.status,
|
|
285
|
+
responseHeaders: Object.entries(replay.headers).map(([name, value]) => ({ name, value: String(value) })),
|
|
286
|
+
body: Buffer.from(replay.body).toString('base64'),
|
|
287
|
+
}, sessionId);
|
|
288
|
+
opts.onEvent?.({ type: 'authorized', detail: { mode: 'token', authorizationId: replay.authorizationId, amountVerified: replay.amountVerified ?? null } });
|
|
289
|
+
}
|
|
67
290
|
}
|
|
68
291
|
catch (err) {
|
|
292
|
+
if (isTerminal(err))
|
|
293
|
+
terminal = err;
|
|
294
|
+
else if (isApprovalOutcome(err))
|
|
295
|
+
quietUntil = Date.now() + cooldownMs;
|
|
69
296
|
opts.onEvent?.({ type: 'failed', detail: String(err) });
|
|
70
297
|
await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
|
|
71
298
|
}
|
|
299
|
+
finally {
|
|
300
|
+
awaitingApproval = false;
|
|
301
|
+
}
|
|
72
302
|
});
|
|
73
303
|
await arm(pageSessionId);
|
|
74
304
|
}
|
|
@@ -95,6 +325,17 @@ export async function attachToPlaywright(page, opts) {
|
|
|
95
325
|
//
|
|
96
326
|
// page.route already spans subframes, so Playwright does the target
|
|
97
327
|
// bookkeeping that attachToCdp has to do by hand for a raw connection.
|
|
328
|
+
// Set once a failure proves that retrying cannot help. The page is free to
|
|
329
|
+
// keep retrying; we simply stop asking the API and fail the request outright.
|
|
330
|
+
let terminal = null;
|
|
331
|
+
// Silence window after a person declined or ignored one; see APPROVAL_COOLDOWN_MS.
|
|
332
|
+
const cooldownMs = opts.approvalCooldownMs ?? APPROVAL_COOLDOWN_MS;
|
|
333
|
+
let quietUntil = 0;
|
|
334
|
+
// One outstanding approval at a time; see the note above isApprovalOutcome.
|
|
335
|
+
let awaitingApproval = false;
|
|
336
|
+
// The hosted form the cardholder already submitted; see HOSTED_FORM_REPEAT_QUIET_MS.
|
|
337
|
+
const repeatQuietMs = opts.hostedFormRepeatQuietMs ?? HOSTED_FORM_REPEAT_QUIET_MS;
|
|
338
|
+
let lastSubmitted = null;
|
|
98
339
|
await page.route((url) => opts.vault.isCardRequest(url.toString()), async (route) => {
|
|
99
340
|
const request = route.request();
|
|
100
341
|
// The matcher only sees the URL; a preflight or a GET must pass through
|
|
@@ -102,22 +343,64 @@ export async function attachToPlaywright(page, opts) {
|
|
|
102
343
|
if (!opts.vault.isCardRequest(request.url(), request.method())) {
|
|
103
344
|
return route.fallback();
|
|
104
345
|
}
|
|
105
|
-
|
|
106
|
-
|
|
346
|
+
// Fail closed and stay quiet: no card may reach the PSP, but neither may
|
|
347
|
+
// the page's retry loop turn into a stream of doomed API calls. Every
|
|
348
|
+
// abort in this adapter is 'aborted' (ERR_ABORTED), the same code the
|
|
349
|
+
// CDP adapter's Fetch.failRequest uses, so a refused navigation
|
|
350
|
+
// 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) });
|
|
354
|
+
return route.abort('aborted');
|
|
355
|
+
}
|
|
356
|
+
// Reserved before anything that could yield, matching attachToCdp.
|
|
357
|
+
awaitingApproval = true;
|
|
107
358
|
try {
|
|
359
|
+
const body = request.postData() ?? '';
|
|
360
|
+
if (isRepeatOfSubmitted(lastSubmitted, request.url(), body, repeatQuietMs)) {
|
|
361
|
+
opts.onEvent?.({ type: 'blocked', detail: HOSTED_FORM_REPEAT_REASON });
|
|
362
|
+
return await route.abort('aborted');
|
|
363
|
+
}
|
|
364
|
+
opts.onEvent?.({ type: 'card_request_paused', detail: { url: redactUrl(request.url()) } });
|
|
108
365
|
const replay = await opts.vault.authorize({
|
|
109
366
|
user: opts.user,
|
|
110
367
|
merchant: opts.merchant,
|
|
111
368
|
amount: opts.amount,
|
|
369
|
+
amountCents: opts.amountCents,
|
|
370
|
+
currency: opts.currency,
|
|
112
371
|
onApprovalUrl: opts.onApprovalUrl,
|
|
113
372
|
request: { url: request.url(), method: request.method(), headers: request.headers(), body },
|
|
114
373
|
});
|
|
115
|
-
|
|
116
|
-
|
|
374
|
+
if (replay.mode === 'hosted_form') {
|
|
375
|
+
// Same as the CDP path: the paused navigation resolves to the
|
|
376
|
+
// synthetic page, and a re-post of this form is refused.
|
|
377
|
+
const synthetic = hostedFormSubmittedPage({ authorizationId: replay.authorizationId, merchant: opts.merchant, submittedAt: replay.submittedAt });
|
|
378
|
+
await route.fulfill({ status: synthetic.status, headers: synthetic.headers, body: synthetic.body });
|
|
379
|
+
lastSubmitted = { url: request.url(), body, at: Date.now() };
|
|
380
|
+
opts.onEvent?.({ type: 'submitted_on_device', detail: { authorizationId: replay.authorizationId, submittedAt: replay.submittedAt, outcome: replay.outcome } });
|
|
381
|
+
}
|
|
382
|
+
else if (replay.mode === 'cse') {
|
|
383
|
+
// Same as the CDP path: the request continues from this browser
|
|
384
|
+
// with the ciphertext swapped in and no header override; Playwright
|
|
385
|
+
// recomputes the length itself.
|
|
386
|
+
await route.continue({ postData: cseBody(body, replay) });
|
|
387
|
+
opts.onEvent?.({ type: 'authorized', detail: { mode: 'cse', authorizationId: replay.authorizationId, fields: Object.keys(replay.substitutions.fields) } });
|
|
388
|
+
}
|
|
389
|
+
else {
|
|
390
|
+
await route.fulfill({ status: replay.status, headers: replay.headers, body: replay.body });
|
|
391
|
+
opts.onEvent?.({ type: 'authorized', detail: { mode: 'token', authorizationId: replay.authorizationId, amountVerified: replay.amountVerified ?? null } });
|
|
392
|
+
}
|
|
117
393
|
}
|
|
118
394
|
catch (err) {
|
|
395
|
+
if (isTerminal(err))
|
|
396
|
+
terminal = err;
|
|
397
|
+
else if (isApprovalOutcome(err))
|
|
398
|
+
quietUntil = Date.now() + cooldownMs;
|
|
119
399
|
opts.onEvent?.({ type: 'failed', detail: String(err) });
|
|
120
|
-
await route.abort();
|
|
400
|
+
await route.abort('aborted');
|
|
401
|
+
}
|
|
402
|
+
finally {
|
|
403
|
+
awaitingApproval = false;
|
|
121
404
|
}
|
|
122
405
|
});
|
|
123
406
|
}
|