@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 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,161 @@ 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
+ | VGS Collect (Very Good Security; Wolt) | not supported: VGS's proxy aliases only submissions from its own iframe, so a replay from the cardholder's device is refused by the merchant (verified on Wolt, 2026-09-03). Not recognized, so the agent's browser is not paused there |
130
+ | Adyen | supported (mode `cse`): the vault encrypts the card for Adyen on the cardholder's device and your browser sends it |
131
+ | 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
132
 
106
133
  The recognizer list is fetched from the API at runtime (`vault.syncRegistry()`),
107
- so new processors work without you shipping a release.
134
+ so new processors work without you shipping a release. `attachToCdp` derives the
135
+ `Fetch.enable` url patterns from that same list rather than a constant, which is
136
+ why the sync call belongs before the attach. `vault.cardUrlPatterns()` returns
137
+ those patterns if you arm a CDP connection yourself. Call
138
+ `GET /v2/checkout/recognizers?modes=token,cse,hosted_form` for the list that is
139
+ live right now.
140
+
141
+ ## Modes
142
+
143
+ Each recognizer carries a `mode` (absent means `token`), and every authorization
144
+ carries the mode it was handled in. The adapters do the right thing for all
145
+ three; the difference matters if you drive `authorize()` yourself.
146
+ `ReplayResponse` is a union, so branch on `mode`.
147
+
148
+ - **`token`** (every processor but Adyen). The cardholder's device calls the
149
+ processor and reports its answer; `authorize()` resolves with `status`,
150
+ `headers` and `body` to fulfill the paused request with. The browser checks
151
+ a fulfilled answer exactly as it checks a real one, so when the page called
152
+ the processor cross-origin (Stripe always does: Checkout on
153
+ `checkout.stripe.com` and Elements in the `js.stripe.com` frame both fetch
154
+ `api.stripe.com`) the answer must carry `access-control-allow-origin` for
155
+ the request's own `Origin`, or the page's fetch rejects and the checkout
156
+ reports a connection error even though the cardholder approved. The
157
+ adapters add those headers (`corsHeadersFor` + `withCorsHeaders`, exported
158
+ for a runtime that fulfills by hand, and `corsDecision` when you also want
159
+ the reason) and report the decision on the `authorized` event as `cors`:
160
+ `echoed`, `same_origin`, or `none` (no usable Origin on the request, so
161
+ the page could not read the answer). Only what a browser serializes is
162
+ echoed: one canonical http(s) origin, or the opaque `null`. Shopify's
163
+ card iframe posts to its own origin, so it never needed them.
164
+ - **`cse`** (Adyen). Adyen's own page SDK encrypts the card before the request
165
+ leaves the browser, so the paused body carries ciphertext. The cardholder's
166
+ device produces the same ciphertext under the merchant's Adyen public key
167
+ (fetched by Agentcard from Adyen's host when the request is parked) and
168
+ `authorize()` resolves with `substitutions: { encoding: 'json', at, fields,
169
+ remove }`. Write them into the paused body with
170
+ `substituteEncryptedFields(body, substitutions)` and CONTINUE the request
171
+ from the same browser (`Fetch.continueRequest` with the rewritten
172
+ `postData`, or Playwright's `route.continue({ postData })`): its session
173
+ data, risk data and cookies must stay its own. Only the four encrypted
174
+ fields change, and the siblings named in `remove` are dropped (Adyen's
175
+ `brand`, which adyen-web derived from the dummy digits the agent typed:
176
+ left in place it names the wrong card and Adyen refuses the mismatch;
177
+ absent, Adyen reads the brand off the card it decrypts). A body that lacks
178
+ the fields throws `SubstitutionError`, which is not terminal. Adyen answers the browser,
179
+ so `charged_kind` is null on the approval and the merchant's order state is
180
+ the outcome to poll.
181
+ - **`hosted_form`** (Tranzila). The processor's hosted card form submits the
182
+ card as a TOP-LEVEL form post, so the paused request is a page navigation
183
+ (the adapters arm `Fetch.enable` with no resource-type filter and attach the
184
+ processor's iframe, which is how a Document request on `direct.tranzila.com`
185
+ gets paused at all). The cardholder's device rebuilds that form with the
186
+ real card and submits it itself; the processor answers the device, and
187
+ `authorize()` resolves with `{ mode: 'hosted_form', kind:
188
+ 'submitted_on_device', outcome: 'unverified', submittedAt }` once the
189
+ device reports the form left. **This is not an approved payment.** The
190
+ stamp is the cardholder's device attesting that the form left it;
191
+ Agentcard holds no processor evidence on this mode and cannot obtain any,
192
+ so the API finishes the authorization as `submitted_on_device` (never
193
+ `approved`) and sends your server `checkout_authorization.submitted`
194
+ (never `.approved`). Treat it as "the person paid, or tried to, on their
195
+ own device" and confirm the order with the merchant before you count it.
196
+ There is no response to replay: FULFIL the paused navigation with
197
+ `hostedFormSubmittedPage({ authorizationId, merchant, submittedAt })` (200,
198
+ `text/html`, `x-agentcard-checkout: submitted_on_device`, a `<meta
199
+ name="agentcard-checkout">` and an inert JSON block saying "submitted on
200
+ the cardholder's device, payment unverified, do not resubmit"), the way
201
+ the adapters do. Do not abort it: an aborted navigation renders nothing,
202
+ the iframe silently keeps its dummy-card form, and the agent's next move is
203
+ to click Pay again. Do not fake the processor's result page either: this
204
+ SDK does not know the outcome. The adapters emit `submitted_on_device` (not
205
+ `authorized`), refuse a byte-identical re-post of the same form for 15
206
+ minutes (`hostedFormRepeatQuietMs`), and the API answers a regenerated one
207
+ with `409 duplicate_submission`, which the adapters quiet the way they quiet
208
+ a decline (`approvalCooldownMs`): the page's immediate re-posts are refused
209
+ without a round trip, and once the prior authorization is declined or
210
+ expired the same form is a new question. Confirm the order with the
211
+ merchant, which learns the outcome from the processor.
212
+
213
+ `syncRegistry()` asks the API for `SUPPORTED_MODES` only
214
+ (`token,cse,hosted_form`), so a processor whose flow this build cannot finish
215
+ is never paused; the API serves `hosted_form` entries only to callers that ask.
216
+ An approval in some other mode throws `UnsupportedModeError`, which is
217
+ terminal for that page (upgrade the SDK). The `authorized` event's detail names
218
+ the `mode`, the `authorizationId` and, for `cse`, the `fields` that were
219
+ substituted; it never carries ciphertext. The `submitted_on_device` event's
220
+ detail names the `authorizationId`, `submittedAt` and `outcome:
221
+ 'unverified'`; it is not an `authorized` event and must not be counted as
222
+ one. `amountAuthority` on every replay is `stripe_payment_intent`,
223
+ `hosted_form_sum` (the form's own amount) or `display_only`.
108
224
 
109
225
  ## Errors worth handling
110
226
 
111
227
  - `ApprovalTimeoutError` — the user never approved. Default window is 15 minutes;
112
228
  we have completed checkouts after a 5.5 minute approval delay.
113
229
  - `ApprovalDeclinedError` — the user said no.
114
- - `CardEncryptedError` this processor needs vault-side crypto; route the
115
- purchase to an Agentcard-issued card instead.
230
+ - `AmountMismatchError`: the processor's amount did not match the amount the
231
+ user was (or would have been) asked to approve. Nothing was charged. An
232
+ `ApprovalDeclinedError` with `expectedCents`, `actualCents`, `currency`,
233
+ `code: 'amount_mismatch'` and `stage`: `'pre_replay'` (checked right before
234
+ the device would have sent the card; `authorizationId` names the declined
235
+ authorization) or `'create'` (the intent already disagreed when the request
236
+ was parked; no authorization exists, `authorizationId` is null). Per
237
+ request, not per page: a merchant can still update an intent's amount
238
+ until it is confirmed, so the adapters quiet the page's immediate retry
239
+ and judge the next request afresh instead of latching.
240
+ - `IntentNotConfirmableError`: the PaymentIntent was already charged, is
241
+ processing, or is authorized and on hold (or canceled), so Agentcard
242
+ refused to replay a confirm at it. An `ApprovalDeclinedError` with
243
+ `code: 'intent_not_confirmable'`. Deliberately not "nothing was charged":
244
+ check the intent at Stripe before retrying.
245
+ - `ProcessorRefusedError`: the cardholder's device sent the card and the
246
+ processor refused it outright (`pspErrorCode` is the processor's own code,
247
+ such as Stripe's `card_declined`). Nothing was charged. An
248
+ `ApprovalDeclinedError` with `code: 'processor_refused'`; the page's next
249
+ attempt raises a fresh approval where the person can pick another card.
250
+ - `CheckoutApiError` with `code === 'amount_unverifiable'`: Stripe could not
251
+ be asked (502; the SDK retries twice, 500ms then 1500ms, before throwing)
252
+ or the paused request lacked its client secret or publishable key (400).
253
+ `code === 'intent_not_confirmable'` at create (409) means the intent was
254
+ already used; the adapters stop intercepting for that page.
255
+ `code === 'cse_key_unavailable'` (502) means Adyen did not answer the
256
+ public-key fetch and is retried the same way; `cse_client_key_unknown`
257
+ (400) means Adyen does not know the merchant's `clientKey`, and
258
+ `cse_template_unsupported` (400) means the paused body carries no
259
+ encrypted card fields to fill (a stored card, a wallet, a single-blob
260
+ `encryptedCard`); both are terminal for that page.
261
+ `hosted_form_template_incomplete` (400) means the paused form lacks a
262
+ field the device fills or the processor requires, `hosted_form_gated`
263
+ (400) means it carries a live captcha token the device could never
264
+ re-submit, and `hosted_form_field_refused` (400, with `field` and a
265
+ `reason` of `stored_credential`, `not_a_sale` or `callback_host`) means
266
+ the form asks the processor for something other than one plain sale
267
+ reporting to the merchant you named (a reusable token in or out, a sale
268
+ mode that is not a sale, a callback URL off the merchant's host: name the
269
+ merchant by its hostname when the form carries callback URLs); all three
270
+ are terminal for that page. `duplicate_submission` (409,
271
+ with `prior_authorization_id` and `prior_status`) means this exact
272
+ submission already has, or already had, its prompt: the adapters quiet the
273
+ page's re-posts for `approvalCooldownMs` (no second notification for one
274
+ payment) and judge the next request afresh, since the prior authorization
275
+ declines or expires and the same form is then a new question.
276
+ - `CardEncryptedError`: this processor encrypts the card in-page and its
277
+ registry entry does not (yet) say the vault can produce that ciphertext;
278
+ route the purchase to an Agentcard-issued card instead.
279
+ - `UnsupportedModeError`: the approval came back in a mode this build of the
280
+ SDK cannot finish. Terminal for the page; upgrade.
281
+ - `SubstitutionError`: a `cse` approval could not be written into the paused
282
+ body (the four encrypted fields were not there). The request is failed and
283
+ the next one is judged afresh.
116
284
 
117
285
  ## Building this package
118
286
 
@@ -122,5 +290,5 @@ be installed here without regenerating the lockfile, and a full regen drifts
122
290
  unrelated transitive versions). Build it with the workspace TypeScript:
123
291
 
124
292
  ```bash
125
- pnpm --filter backend exec tsc -p packages/checkout/tsconfig.json
293
+ cd packages/checkout && pnpm build && pnpm test
126
294
  ```
package/dist/cdp.d.ts CHANGED
@@ -1,4 +1,67 @@
1
- import type { VaultClient } from './client.js';
1
+ import { type VaultClient } from './client.js';
2
+ /**
3
+ * The CORS headers a fulfilled CROSS-ORIGIN request needs, or null when the
4
+ * request is same-origin (or carries no Origin, so no CORS check applies).
5
+ *
6
+ * The browser checks a fulfilled response exactly as it checks a real one. A
7
+ * page that fetches a processor on another origin therefore needs
8
+ * `access-control-allow-origin` on the synthetic answer, or its fetch rejects
9
+ * with "Failed to fetch" and the page never sees the processor's reply, even
10
+ * though the cardholder approved and the processor answered the vault.
11
+ * Shopify never hit this on its current host: the checkout.pci.shopifyinc.com
12
+ * card iframe posts to its own origin (its older deposit.<region>.shopifycs.com
13
+ * host is called from the checkout.shopifycs.com frame, cross-origin, and gets
14
+ * the answer like everyone else). Stripe hits it on every surface (Checkout on
15
+ * checkout.stripe.com or a merchant domain, and Elements in the js.stripe.com
16
+ * frame, all call api.stripe.com), and so does every other processor whose
17
+ * card frame calls a separate API host. Observed live on
18
+ * 2026-09-03: the vault replayed a Stripe PaymentMethod into a raw-CDP
19
+ * runtime, the browser refused the answer for want of this header, and
20
+ * Stripe Checkout showed "We are experiencing connection issues".
21
+ *
22
+ * The exact Origin is echoed rather than `*`: a credentialed request refuses
23
+ * `*`, the echo satisfies both. The processor's own value can never reach
24
+ * this adapter (a browser does not expose that header to the page that
25
+ * replayed the call), so whatever the replay carries under these names is
26
+ * replaced by the one value that is right for THIS request. Playwright adds
27
+ * the same headers inside route.fulfill when a cross-origin fulfill carries
28
+ * none (microsoft/playwright#12929), which is why attachToPlaywright never
29
+ * needed this; it writes them itself anyway, replacing a stale value, so both
30
+ * adapters answer the vault's replays identically.
31
+ *
32
+ * This widens nothing. A tokenization endpoint is built for anonymous
33
+ * browsers and answers every origin (`access-control-allow-origin: *` on
34
+ * Stripe's and Shopify's own replies), so the page that made the request
35
+ * could always read the processor's answer to it; the replay is made exactly
36
+ * as visible, to exactly that page. Whether a card goes anywhere at all is
37
+ * decided by the cardholder on the approval screen, never by this header.
38
+ *
39
+ * Only what a browser serializes is ever echoed: one canonical http(s)
40
+ * origin (`new URL(origin).origin === origin`), or the opaque `null` a
41
+ * sandboxed or data: document sends, which Chrome matches against
42
+ * `access-control-allow-origin: null` and which Playwright echoes too. That
43
+ * refuses userinfo, a path, an explicit default port, several origins in one
44
+ * value, or a control character that would break the fulfill after the
45
+ * cardholder already approved. Anything refused simply gets no CORS answer,
46
+ * which is what every fulfill got before this existed.
47
+ */
48
+ export declare function corsHeadersFor(url: string, requestHeaders: Record<string, string> | undefined): Record<string, string> | null;
49
+ /** How a fulfill was answered, reported on the `authorized` event so a silent CORS failure is diagnosable from events alone. */
50
+ export type CorsOutcome = 'echoed' | 'same_origin' | 'none';
51
+ /** 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. */
52
+ export declare function corsDecision(url: string, requestHeaders: Record<string, string> | undefined): {
53
+ headers: Record<string, string> | null;
54
+ outcome: CorsOutcome;
55
+ };
56
+ /**
57
+ * `headers` with the CORS answer for this request written in; the object
58
+ * itself when none is needed. Any header the answer names is replaced
59
+ * whatever its case, so a name is never sent twice. The answer varies by
60
+ * Origin, and a `vary` the replay already carries is extended rather than
61
+ * replaced (or left alone when it already covers Origin or is `*`); a `vary`
62
+ * the answer itself names is taken as given.
63
+ */
64
+ export declare function withCorsHeaders(headers: Record<string, string>, cors: Record<string, string> | null): Record<string, string>;
2
65
  /**
3
66
  * Minimal shape of a CDP connection. Works with a raw websocket client, a
4
67
  * Puppeteer CDPSession, or Playwright's CDPSession.
@@ -11,12 +74,25 @@ export interface AttachOptions {
11
74
  vault: VaultClient;
12
75
  user: string;
13
76
  merchant: string;
14
- amount: string;
77
+ /** Display string for the approval screen. Optional when amountCents + currency are given. */
78
+ amount?: string;
79
+ /**
80
+ * The amount as a number (smallest currency unit) with its ISO 4217 code.
81
+ * See AuthorizeInput.amountCents: on a Stripe PaymentIntent confirm this
82
+ * binds the approval to the intent's amount, checked at create and again
83
+ * right before replay, so a larger charge is refused with nothing charged.
84
+ */
85
+ amountCents?: number;
86
+ currency?: string;
15
87
  onApprovalUrl?: (url: string) => void;
16
88
  onEvent?: (e: {
17
89
  type: string;
18
90
  detail?: unknown;
19
91
  }) => void;
92
+ /** Silence after a decline or timeout. Defaults to APPROVAL_COOLDOWN_MS. */
93
+ approvalCooldownMs?: number;
94
+ /** How long a hosted form the device already submitted stays refused on a re-post. Defaults to HOSTED_FORM_REPEAT_QUIET_MS. */
95
+ hostedFormRepeatQuietMs?: number;
20
96
  }
21
97
  /**
22
98
  * Take over card tokenization for a page.
@@ -25,6 +101,15 @@ export interface AttachOptions {
25
101
  * targets. Enabling Fetch on the page session alone will never see the
26
102
  * tokenization request. This attaches recursively so every nested target is
27
103
  * armed, which is the whole reason this adapter exists.
104
+ *
105
+ * The patterns armed here come from the REGISTRY, not from a constant, so a PSP
106
+ * the API knows about is paused without an SDK release — call
107
+ * `vault.syncRegistry()` before attaching and every recognizer the server
108
+ * serves is covered. They are a coarse pre-filter and are deliberately wider
109
+ * than the recognizers (see cardUrlPatterns): each paused request is re-checked
110
+ * with `isCardRequest` below and continued untouched unless it is an exact
111
+ * match. Patterns are resolved once, at attach, so every nested target ends up
112
+ * armed identically.
28
113
  */
29
114
  export declare function attachToCdp(cdp: CdpLike, pageSessionId: string, opts: AttachOptions): Promise<void>;
30
115
  /**