@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 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
- amount: '$5.83',
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 | not yet the card is encrypted in-page, so a paused request carries a blob. Throws `CardEncryptedError` |
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
- - `CardEncryptedError` this processor needs vault-side crypto; route the
115
- purchase to an Agentcard-issued card instead.
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
- pnpm --filter backend exec tsc -p packages/checkout/tsconfig.json
279
+ cd packages/checkout && pnpm build && pnpm test
126
280
  ```
package/dist/cdp.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { VaultClient } from './client.js';
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
- amount: string;
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
- const CARD_PATTERNS = [
2
- '*pci.shopifyinc.com/sessions*',
3
- '*shopifycs.com/sessions*',
4
- '*api.stripe.com/v1/payment_methods*',
5
- '*api.stripe.com/v1/tokens*',
6
- '*braintree-api.com/graphql*',
7
- '*checkout.com/tokens*',
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: CARD_PATTERNS.map((urlPattern) => ({ urlPattern, requestStage: 'Request' })),
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
- let body = request.postData ?? '';
48
- if (!body && request.hasPostData) {
49
- body = (await cdp.send('Fetch.getRequestPostData', { requestId }, sessionId).catch(() => ({ postData: '' }))).postData ?? '';
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
- opts.onEvent?.({ type: 'card_request_paused', detail: { url: request.url } });
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
- await cdp.send('Fetch.fulfillRequest', {
61
- requestId,
62
- responseCode: replay.status,
63
- responseHeaders: Object.entries(replay.headers).map(([name, value]) => ({ name, value: String(value) })),
64
- body: Buffer.from(replay.body).toString('base64'),
65
- }, sessionId);
66
- opts.onEvent?.({ type: 'authorized' });
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
- const body = request.postData() ?? '';
106
- opts.onEvent?.({ type: 'card_request_paused', detail: { url: request.url() } });
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
- await route.fulfill({ status: replay.status, headers: replay.headers, body: replay.body });
116
- opts.onEvent?.({ type: 'authorized' });
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
  }