@agent-cards/checkout 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0
4
+
5
+ - Prepare Braintree card checkout with `controller.prepare({ psp: 'braintree', environment: 'production' | 'sandbox' })` before the merchant's first Pay action. The cardholder approves and unlocks first; one fresh native request then uses that approval without another notification. The matching API and Vault release is required.
6
+ - Let native Braintree client-configuration queries pass without consuming consent. Prepared checkout accepts one card-tokenization mutation for the approved processor and environment. Other operations, ambiguous GraphQL payloads and legacy REST fallback cannot use the preparation.
7
+ - Keep the native request timeout and one-use, card, merchant, amount, currency and document bindings. Approval readiness lasts at most 30 seconds. Tokenization amounts remain display-only; a token does not establish a paid order.
8
+ - Add a real Chromium fixture with approval delayed 61 seconds before a fresh 60-second XHR, plus cancellation, request classification and binding regressions. The isolated fixture contacts no payment processor and is not live purchase proof.
9
+
3
10
  ## 0.4.1
4
11
 
5
12
  - Stop describing every processor request rejection as a card decline or proof that nothing was charged. `ProcessorRefusedError` keeps its existing class and code, with neutral wording and an optional `processorError` containing bounded Razorpay reason, source, step and payment/order identifiers from the matching API/Vault release. Older failure records cannot recover details that were not retained.
package/README.md CHANGED
@@ -82,7 +82,7 @@ pair is shown and reported (`amountAuthority: 'display_only'`), not enforced.
82
82
  Then let your agent click "Pay" like it always does. `attachToCdp` pauses the
83
83
  request for approval and resumes it only while the merchant request remains
84
84
  live. Merchant timeouts still apply: Square's observed tokenization deadline
85
- is about 10 seconds for approval and token handoff. For human approval, use
85
+ is about 10 seconds, and Braintree's native request timeout is 60 seconds, including approval and token handoff. For human approval, use
86
86
  `controller.prepare()` before the first Pay action as shown below.
87
87
 
88
88
  Playwright:
@@ -130,7 +130,7 @@ Coverage is specific to the processor request format, merchant setup, browser tr
130
130
  |---|---|
131
131
  | Shopify | supported, verified end to end |
132
132
  | Stripe | tokenization replay and direct card-bearing PaymentIntent confirms are implemented; direct confirms with `amountCents` + `currency` use backend amount verification. Browser token-to-intent continuation is unsupported and held. Validate the exact merchant flow before pilot use |
133
- | Braintree / PayPal | supported, verified end to end |
133
+ | Braintree card tokenization | prepared checkout supported; production paid-order validation pending |
134
134
  | Checkout.com | supported |
135
135
  | 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 |
136
136
  | Adyen | supported (mode `cse`): the vault encrypts the card for Adyen on the cardholder's device and your browser sends it |
@@ -400,7 +400,7 @@ do not reuse that token in a new attachment as a workaround. Configuration and
400
400
  unsupported-mode failures require fixing the integration. Bank flows requiring
401
401
  another confirmation and other stored-token chains remain unverified.
402
402
 
403
- Square's observed native tokenization request expires after about 10 seconds. SDK 0.4.0 adds approval before submission, requiring the matching preparation API and Vault deployment. Start human approval before the caller's first Pay action:
403
+ Prepare a Square or Braintree checkout before the first Pay action so the cardholder can approve before the native card request starts. The API and Vault deployments must support the selected processor:
404
404
 
405
405
  ```ts
406
406
  const checkout = await attachToPlaywright(page, {
@@ -409,19 +409,21 @@ const checkout = await attachToPlaywright(page, {
409
409
  onApprovalUrl: deliverPrivatelyToCardholder,
410
410
  });
411
411
  const preparation = await checkout.prepare({
412
- psp: 'square',
413
- environment: 'production', // explicit; use 'sandbox' for Square Sandbox
412
+ psp: 'braintree', // use 'square' for Square
413
+ environment: 'production', // explicit; use 'sandbox' for the processor's sandbox
414
414
  });
415
415
  // The cardholder has consented and unlocked the same approval document.
416
416
  // No processor request or payment has started.
417
417
  await page.getByRole('button', { name: 'Pay', exact: true }).click();
418
418
  ```
419
419
 
420
- `prepare()` is available on both Playwright and raw CDP controllers. It requires `amountCents` and `currency`, must precede the first recognized card request, and returns only when the cardholder's device is ready. It delivers the preparation URL through `onApprovalUrl` and `onUserAction`; binding the subsequent authorization sends no second approval link or SMS. The phone page must stay open. Its selected card, merchant origin, declared merchant, amount, currency and Square environment bind one fresh request. The amount remains `display_only`; a Square token does not enforce the merchant's eventual charge amount.
420
+ `prepare()` is available on both Playwright and raw CDP controllers. It requires `amountCents` and `currency`, must precede the first recognized card request, and returns only when the cardholder's device is ready. It delivers the preparation URL through `onApprovalUrl` and `onUserAction`; binding the subsequent authorization sends no second approval link or SMS. The phone page must stay open. Its selected card, merchant origin, declared merchant, amount, currency, processor and environment bind one fresh request. The amount remains `display_only`; a card token does not enforce the merchant's eventual charge amount.
421
421
 
422
- Readiness lasts up to 30 seconds (`preparation.expiresAt`) and appears as `ready_to_submit`, with `paymentStatus: 'not_started'`. Trigger the caller-owned Pay action immediately after the promise resolves. Expiry, navigation, cancellation, an early request or a changed checkout fails closed. A preparation and its attachment are single use; reconcile any bound authorization before creating a new attachment. The SDK never clicks Pay, reuses a stale request, pauses Square timers, or automatically retries a failed prepared checkout.
422
+ Braintree's native `ClientConfiguration` GraphQL query can run before, during or after preparation without using the approval. Only a single `TokenizeCreditCard` mutation can consume the prepared Braintree checkout. Prepared requests require guest card tokenization with explicit `options.validate: false`; omitted validation options, saved-card fields and `validate: true` are refused. Other GraphQL operations, batches and compound mutations are blocked. Braintree's legacy REST fallback cannot consume a prepared authorization.
423
423
 
424
- After Pay, Square's native deadline still covers fresh authorization binding, device replay, relay and token handoff. A disconnected/backgrounded phone or slow transport can still miss it. A subsequent SCA challenge has its own lifetime after token handoff. If the merchant request aborts or its frame closes, the attachment blocks further requests and tries to retire the pre-replay authorization; a started replay or unconfirmed cancellation remains unknown. Without `prepare()`, approval loading and human interaction still share the native deadline, so delayed approval cannot finish that request.
424
+ Readiness lasts up to 30 seconds (`preparation.expiresAt`) and appears as `ready_to_submit`, with `paymentStatus: 'not_started'`. Trigger the caller-owned Pay action immediately after the promise resolves. Expiry, navigation, cancellation, an early request or a changed checkout fails closed. A preparation and its attachment are single use; reconcile any bound authorization before creating a new attachment. The SDK never clicks Pay, reuses a stale request, changes native request deadlines, or automatically retries a failed prepared checkout.
425
+
426
+ After Pay, the processor's native deadline still covers fresh authorization binding, device replay and token handoff. A disconnected/backgrounded phone or slow transport can still miss it. A subsequent SCA challenge has its own lifetime after token handoff. If the merchant request aborts or its frame closes, the attachment blocks further requests and tries to retire the pre-replay authorization; a started replay or unconfirmed cancellation remains unknown. Without `prepare()`, approval loading and human interaction still share the native deadline, so delayed approval cannot finish that request.
425
427
 
426
428
  Lost authorization polling, local approval timeouts, or interrupted browser
427
429
  handoffs produce `outcome_unknown` and block automatic retry. The thrown
@@ -0,0 +1,8 @@
1
+ /** Classify native Braintree GraphQL requests without changing their bytes. */
2
+ export type BraintreeEnvironment = 'production' | 'sandbox';
3
+ export type BraintreeRequestKind = 'configuration' | 'tokenization' | 'invalid';
4
+ export declare function braintreeEnvironment(url: string): BraintreeEnvironment | undefined;
5
+ /** Undefined belongs to another processor; invalid Braintree requests stay blocked. */
6
+ export declare function classifyBraintreeRequest(url: string, method: string, body: string | null): BraintreeRequestKind | undefined;
7
+ /** Preparation accepts guest tokenization only; ordinary interception stays broader. */
8
+ export declare function isPreparedBraintreeRequest(body: string | null): boolean;
@@ -0,0 +1,290 @@
1
+ const PRODUCTION = 'https://payments.braintree-api.com/graphql';
2
+ const SANDBOX = 'https://payments.sandbox.braintree-api.com/graphql';
3
+ const MAX_BODY_LENGTH = 128 * 1024;
4
+ const MAX_DEPTH = 32;
5
+ const MAX_NODES = 10_000;
6
+ export function braintreeEnvironment(url) {
7
+ return url === PRODUCTION ? 'production' : url === SANDBOX ? 'sandbox' : undefined;
8
+ }
9
+ function invalid() { throw new Error('invalid_braintree_request'); }
10
+ function record(value) {
11
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
12
+ }
13
+ /** JSON.parse discards duplicate keys, so validate keys before using any value. */
14
+ class JsonReader {
15
+ text;
16
+ at = 0;
17
+ nodes = 0;
18
+ constructor(text) {
19
+ this.text = text;
20
+ }
21
+ read() {
22
+ const value = this.value(0);
23
+ this.space();
24
+ if (this.at !== this.text.length)
25
+ invalid();
26
+ return value;
27
+ }
28
+ space() {
29
+ while (' \t\r\n'.includes(this.text[this.at] ?? '\0'))
30
+ this.at++;
31
+ }
32
+ take(expected) {
33
+ this.space();
34
+ if (this.text[this.at++] !== expected)
35
+ invalid();
36
+ }
37
+ string() {
38
+ this.space();
39
+ const start = this.at;
40
+ if (this.text[this.at++] !== '"')
41
+ invalid();
42
+ while (this.at < this.text.length) {
43
+ const char = this.text[this.at++];
44
+ if (char === '\\') {
45
+ this.at++;
46
+ continue;
47
+ }
48
+ if (char === '"')
49
+ return JSON.parse(this.text.slice(start, this.at));
50
+ }
51
+ return invalid();
52
+ }
53
+ value(depth) {
54
+ if (depth > MAX_DEPTH || ++this.nodes > MAX_NODES)
55
+ invalid();
56
+ this.space();
57
+ const char = this.text[this.at];
58
+ if (char === '"')
59
+ return this.string();
60
+ if (char === '{') {
61
+ this.at++;
62
+ const result = Object.create(null);
63
+ const keys = new Set();
64
+ this.space();
65
+ if (this.text[this.at] === '}') {
66
+ this.at++;
67
+ return result;
68
+ }
69
+ while (true) {
70
+ const key = this.string();
71
+ if (keys.has(key))
72
+ invalid();
73
+ keys.add(key);
74
+ this.take(':');
75
+ result[key] = this.value(depth + 1);
76
+ this.space();
77
+ const next = this.text[this.at++];
78
+ if (next === '}')
79
+ return result;
80
+ if (next !== ',')
81
+ invalid();
82
+ }
83
+ }
84
+ if (char === '[') {
85
+ this.at++;
86
+ const result = [];
87
+ this.space();
88
+ if (this.text[this.at] === ']') {
89
+ this.at++;
90
+ return result;
91
+ }
92
+ while (true) {
93
+ result.push(this.value(depth + 1));
94
+ this.space();
95
+ const next = this.text[this.at++];
96
+ if (next === ']')
97
+ return result;
98
+ if (next !== ',')
99
+ invalid();
100
+ }
101
+ }
102
+ for (const [literal, value] of [['true', true], ['false', false], ['null', null]]) {
103
+ if (this.text.startsWith(literal, this.at)) {
104
+ this.at += literal.length;
105
+ return value;
106
+ }
107
+ }
108
+ const number = /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/.exec(this.text.slice(this.at));
109
+ if (!number)
110
+ invalid();
111
+ this.at += number[0].length;
112
+ const value = Number(number[0]);
113
+ if (!Number.isFinite(value))
114
+ invalid();
115
+ return value;
116
+ }
117
+ }
118
+ /** Native operations need names, punctuation and variables, never literals. */
119
+ function lex(query) {
120
+ const tokens = [];
121
+ let at = 0;
122
+ while (at < query.length) {
123
+ const char = query[at];
124
+ if (' \t\r\n,'.includes(char)) {
125
+ at++;
126
+ continue;
127
+ }
128
+ if ('!$():{}'.includes(char)) {
129
+ tokens.push(char);
130
+ at++;
131
+ }
132
+ else if (/[A-Za-z_]/.test(char)) {
133
+ const start = at++;
134
+ while (at < query.length && /[A-Za-z_0-9]/.test(query[at]))
135
+ at++;
136
+ tokens.push(query.slice(start, at));
137
+ }
138
+ else
139
+ invalid(); // Includes comments, strings, fragments and directives.
140
+ if (tokens.length > MAX_NODES)
141
+ invalid();
142
+ }
143
+ return tokens;
144
+ }
145
+ class GraphqlReader {
146
+ tokens;
147
+ at = 0;
148
+ constructor(tokens) {
149
+ this.tokens = tokens;
150
+ }
151
+ peek() { return this.tokens[this.at]; }
152
+ take(expected) {
153
+ if (this.tokens[this.at++] !== expected)
154
+ invalid();
155
+ }
156
+ name() {
157
+ const value = this.tokens[this.at++];
158
+ if (!value || !/^[A-Za-z_][A-Za-z_0-9]*$/.test(value))
159
+ invalid();
160
+ return value;
161
+ }
162
+ read() {
163
+ const kind = this.name();
164
+ const name = this.name();
165
+ let inputDefinition = false;
166
+ if (this.peek() === '(') {
167
+ // No defaults, alternate types, unused variables or duplicate definitions.
168
+ for (const token of ['(', '$', 'input', ':', 'TokenizeCreditCardInput', '!', ')'])
169
+ this.take(token);
170
+ inputDefinition = true;
171
+ }
172
+ const fields = this.selection(0);
173
+ if (this.at !== this.tokens.length)
174
+ invalid();
175
+ return { kind, name, inputDefinition, fields };
176
+ }
177
+ selection(depth) {
178
+ if (depth > MAX_DEPTH)
179
+ invalid();
180
+ this.take('{');
181
+ const fields = [];
182
+ const names = new Set();
183
+ while (this.peek() !== '}') {
184
+ const name = this.name();
185
+ if (names.has(name))
186
+ invalid();
187
+ names.add(name);
188
+ const args = [];
189
+ if (this.peek() === '(') {
190
+ if (depth !== 0)
191
+ invalid();
192
+ this.take('(');
193
+ const argument = this.name();
194
+ this.take(':');
195
+ this.take('$');
196
+ args.push({ name: argument, variable: this.name() });
197
+ this.take(')');
198
+ }
199
+ const children = this.peek() === '{' ? this.selection(depth + 1) : [];
200
+ fields.push({ name, arguments: args, fields: children });
201
+ // Aliases, directives and other punctuation cannot begin the next field.
202
+ }
203
+ this.take('}');
204
+ if (fields.length === 0)
205
+ invalid();
206
+ return fields;
207
+ }
208
+ }
209
+ /** Undefined belongs to another processor; invalid Braintree requests stay blocked. */
210
+ export function classifyBraintreeRequest(url, method, body) {
211
+ let hostname;
212
+ try {
213
+ hostname = new URL(url).hostname;
214
+ }
215
+ catch {
216
+ return undefined;
217
+ }
218
+ if (hostname !== 'payments.braintree-api.com' && hostname !== 'payments.sandbox.braintree-api.com')
219
+ return undefined;
220
+ if (!braintreeEnvironment(url) || method !== 'POST' || typeof body !== 'string' || body.length === 0 || body.length > MAX_BODY_LENGTH)
221
+ return 'invalid';
222
+ try {
223
+ const envelope = new JsonReader(body).read();
224
+ if (!record(envelope) || typeof envelope.query !== 'string' || typeof envelope.operationName !== 'string')
225
+ return 'invalid';
226
+ if (Object.keys(envelope).some(key => !['query', 'operationName', 'variables', 'clientSdkMetadata'].includes(key)))
227
+ return 'invalid';
228
+ if (envelope.clientSdkMetadata !== undefined && !record(envelope.clientSdkMetadata))
229
+ return 'invalid';
230
+ const operation = new GraphqlReader(lex(envelope.query)).read();
231
+ if (operation.name !== envelope.operationName || operation.fields.length !== 1)
232
+ return 'invalid';
233
+ const root = operation.fields[0];
234
+ if (root.fields.length === 0)
235
+ return 'invalid';
236
+ const variables = envelope.variables === undefined ? Object.create(null) : envelope.variables;
237
+ if (!record(variables))
238
+ return 'invalid';
239
+ if (operation.kind === 'query' && ['ClientConfiguration', 'ClientConfigurationQuery'].includes(operation.name)
240
+ && !operation.inputDefinition && root.name === 'clientConfiguration' && root.arguments.length === 0
241
+ && Object.keys(variables).length === 0)
242
+ return 'configuration';
243
+ if (operation.kind !== 'mutation' || operation.name !== 'TokenizeCreditCard' || !operation.inputDefinition
244
+ || root.name !== 'tokenizeCreditCard' || root.arguments.length !== 1
245
+ || root.arguments[0].name !== 'input' || root.arguments[0].variable !== 'input'
246
+ || Object.keys(variables).length !== 1 || !record(variables.input) || !record(variables.input.creditCard))
247
+ return 'invalid';
248
+ const card = variables.input.creditCard;
249
+ if (!['number', 'expirationMonth', 'expirationYear'].every(key => typeof card[key] === 'string' && card[key].trim().length > 0))
250
+ return 'invalid';
251
+ if (card.cvv !== undefined && (typeof card.cvv !== 'string' || card.cvv.trim().length === 0))
252
+ return 'invalid';
253
+ return 'tokenization';
254
+ }
255
+ catch {
256
+ return 'invalid';
257
+ }
258
+ }
259
+ /** Preparation accepts guest tokenization only; ordinary interception stays broader. */
260
+ export function isPreparedBraintreeRequest(body) {
261
+ if (classifyBraintreeRequest(PRODUCTION, 'POST', body) !== 'tokenization')
262
+ return false;
263
+ try {
264
+ const envelope = new JsonReader(body).read();
265
+ const variables = envelope.variables;
266
+ const input = variables.input;
267
+ if (Object.keys(input).some(key => key !== 'creditCard' && key !== 'options'))
268
+ return false;
269
+ const card = input.creditCard;
270
+ if (Object.keys(card).some(key => !['number', 'expirationMonth', 'expirationYear', 'cvv', 'cardholderName', 'billingAddress'].includes(key)))
271
+ return false;
272
+ if (Object.entries(card).some(([key, value]) => key !== 'billingAddress' && typeof value !== 'string'))
273
+ return false;
274
+ if (card.billingAddress !== undefined) {
275
+ if (!record(card.billingAddress))
276
+ return false;
277
+ const addressKeys = ['postalCode', 'firstName', 'lastName', 'company', 'streetAddress', 'extendedAddress',
278
+ 'locality', 'region', 'countryCodeNumeric', 'countryCodeAlpha2', 'countryCodeAlpha3', 'countryName'];
279
+ if (Object.entries(card.billingAddress).some(([key, value]) => !addressKeys.includes(key) || typeof value !== 'string'))
280
+ return false;
281
+ }
282
+ // Fingerprint authorization defaults validation on when the option is absent.
283
+ // Preparation permits only an explicit request for a transient card token.
284
+ return record(input.options) && Object.keys(input.options).every(key => key === 'validate')
285
+ && input.options.validate === false;
286
+ }
287
+ catch {
288
+ return false;
289
+ }
290
+ }
package/dist/cdp.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { BUILTIN_REGISTRY, cardUrlPatterns } from './registry.js';
2
2
  import { ApprovalDeclinedError, ApprovalTimeoutError, CardEncryptedError, CheckoutApiError, PaymentOutcomeUnknownError, ProcessorRefusedError, UnsupportedModeError, redactUrl, } from './client.js';
3
3
  import { PreparationGate } from './preparation.js';
4
+ import { classifyBraintreeRequest } from './braintree.js';
4
5
  import { substituteEncryptedFields } from './substitute.js';
5
6
  import { hostedFormSubmittedPage } from './hosted-form.js';
6
7
  import { CheckoutLifecycle, paymentEndpointGuards } from './lifecycle.js';
@@ -413,6 +414,19 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
413
414
  if (method !== 'Fetch.requestPaused')
414
415
  return;
415
416
  const { requestId, request, resourceType, networkId, frameId } = params;
417
+ // Braintree shares /graphql between configuration and card mutations.
418
+ // Classify before URL-only recognition or claiming a prepared request.
419
+ const braintree = request.method.toUpperCase() === 'POST'
420
+ ? classifyBraintreeRequest(request.url, request.method, pausedBody(request)) : undefined;
421
+ if (braintree === 'configuration') {
422
+ await cdp.send('Fetch.continueRequest', { requestId }, sessionId).catch(() => { });
423
+ return;
424
+ }
425
+ if (braintree === 'invalid') {
426
+ opts.onEvent?.({ type: 'blocked', detail: 'unsupported_braintree_graphql_operation' });
427
+ await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
428
+ return;
429
+ }
416
430
  if (!opts.vault.isCardRequest(request.url, request.method)) {
417
431
  if (guards.matches(request.url, request.method)) {
418
432
  preparationGate.invalidate('unsupported_checkout');
@@ -426,7 +440,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
426
440
  }
427
441
  let preparation;
428
442
  try {
429
- preparation = preparationGate.claim(request.url);
443
+ preparation = preparationGate.claim(request.url, braintree === 'tokenization' ? pausedBody(request) : undefined);
430
444
  }
431
445
  catch (error) {
432
446
  opts.onEvent?.({ type: 'blocked', detail: failureSummary(error) });
@@ -646,6 +660,14 @@ export async function attachToPlaywright(page, opts) {
646
660
  let lastSubmitted = null;
647
661
  await page.route((url) => opts.vault.isCardRequest(url.toString()) || guards.matches(url.toString()), async (route) => {
648
662
  const request = route.request();
663
+ const braintree = request.method().toUpperCase() === 'POST'
664
+ ? classifyBraintreeRequest(request.url(), request.method(), request.postData() ?? '') : undefined;
665
+ if (braintree === 'configuration')
666
+ return route.fallback();
667
+ if (braintree === 'invalid') {
668
+ opts.onEvent?.({ type: 'blocked', detail: 'unsupported_braintree_graphql_operation' });
669
+ return route.abort('aborted');
670
+ }
649
671
  // The matcher only sees the URL; a preflight or a GET must pass through
650
672
  // untouched or the browser's CORS check fails on our synthetic answer.
651
673
  if (!opts.vault.isCardRequest(request.url(), request.method())) {
@@ -659,7 +681,7 @@ export async function attachToPlaywright(page, opts) {
659
681
  }
660
682
  let preparation;
661
683
  try {
662
- preparation = preparationGate.claim(request.url());
684
+ preparation = preparationGate.claim(request.url(), braintree === 'tokenization' ? request.postData() ?? '' : undefined);
663
685
  }
664
686
  catch (error) {
665
687
  opts.onEvent?.({ type: 'blocked', detail: failureSummary(error) });
package/dist/client.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type CheckoutMode, type Recognizer } from './registry.js';
2
2
  import type { Substitutions } from './substitute.js';
3
+ import { type PreparationProcessor } from './prepared-processor.js';
3
4
  export interface PausedRequest {
4
5
  url: string;
5
6
  method: string;
@@ -99,7 +100,7 @@ export interface HostedFormReplay {
99
100
  /** What authorize() resolves with; branch on `mode` (absent means token). */
100
101
  export type ReplayResponse = TokenReplay | CseReplay | HostedFormReplay;
101
102
  export interface PrepareCheckoutOptions {
102
- psp: 'square';
103
+ psp: PreparationProcessor;
103
104
  /** The processor environment, independent of your Agentcard client's mode. */
104
105
  environment: 'production' | 'sandbox';
105
106
  signal?: AbortSignal;
@@ -120,7 +121,7 @@ export interface PrepareCheckoutInput extends PrepareCheckoutOptions {
120
121
  export interface PreparedCheckout {
121
122
  readonly id: string;
122
123
  readonly status: 'ready';
123
- readonly psp: 'square';
124
+ readonly psp: PreparationProcessor;
124
125
  readonly environment: 'production' | 'sandbox';
125
126
  readonly expiresAt: string;
126
127
  readonly cardId: string;
package/dist/client.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { BUILTIN_REGISTRY, cardUrlPatterns as deriveCardUrlPatterns, findRecognizer, } from './registry.js';
2
+ import { matchesPreparedRequest } from './prepared-processor.js';
2
3
  /**
3
4
  * The modes this SDK can finish. Asked for on syncRegistry (the API serves
4
5
  * only recognizers in these modes, so a request this build cannot complete
@@ -314,7 +315,7 @@ export class VaultClient {
314
315
  async prepareCheckout(input) {
315
316
  input = { ...input };
316
317
  const fail = (reason, id = null) => new CheckoutPreparationError(id, reason);
317
- if (input.psp !== 'square' || !['production', 'sandbox'].includes(input.environment))
318
+ if (!['square', 'braintree'].includes(input.psp) || !['production', 'sandbox'].includes(input.environment))
318
319
  throw fail('unsupported_processor');
319
320
  if (!Number.isSafeInteger(input.amountCents) || input.amountCents <= 0 || typeof input.currency !== 'string' || !/^[a-z]{3}$/i.test(input.currency))
320
321
  throw fail('amount_required');
@@ -367,7 +368,7 @@ export class VaultClient {
367
368
  || state.payment_status !== 'not_started' || state.amount_authority !== 'display_only'
368
369
  || state.user !== input.user || state.merchant !== input.merchant || state.merchant_origin !== input.merchantOrigin
369
370
  || state.amount_cents !== input.amountCents || state.currency !== input.currency.toLowerCase()
370
- || state.psp !== 'square' || state.mode !== 'token' || state.environment !== input.environment
371
+ || state.psp !== input.psp || state.mode !== 'token' || state.environment !== input.environment
371
372
  || state.checkout_key !== input.checkoutKey)
372
373
  throw fail('ready_unconfirmed', id);
373
374
  const prepared = Object.freeze({
@@ -416,13 +417,11 @@ export class VaultClient {
416
417
  throw new CheckoutPreparationError(preparation.id ?? null, 'already_used_or_foreign');
417
418
  // Consume locally before any await, including OAuth, and never recycle it.
418
419
  this.usedPreparations.add(preparation);
419
- const url = new URL(input.request.url);
420
- const host = preparation.environment === 'production' ? 'pci-connect.squareup.com' : 'pci-connect.squareupsandbox.com';
421
420
  if (Date.parse(preparation.expiresAt) <= Date.now())
422
421
  throw new CheckoutPreparationError(preparation.id, 'expired');
423
422
  if (input.user !== preparation.user || input.merchant !== preparation.merchant || input.amountCents !== preparation.amountCents
424
423
  || input.currency?.toLowerCase() !== preparation.currency || input.cardId !== preparation.cardId
425
- || url.origin !== `https://${host}` || url.pathname !== '/v2/card-nonce' || url.username || url.password)
424
+ || !matchesPreparedRequest(preparation.psp, preparation.environment, input.request.url, input.request.method ?? 'POST', input.request.body))
426
425
  throw new CheckoutPreparationError(preparation.id, 'checkout_changed');
427
426
  }
428
427
  if (input.signal?.aborted)
@@ -437,6 +436,8 @@ export class VaultClient {
437
436
  // clientSideEncrypted entry (an older registry, a hand-built one) still
438
437
  // refuses here, exactly as before.
439
438
  const mode = rec.mode ?? 'token';
439
+ if (preparation && (rec.psp !== preparation.psp || mode !== 'token'))
440
+ throw new CheckoutPreparationError(preparation.id, 'checkout_changed');
440
441
  if (rec.clientSideEncrypted && mode !== 'cse')
441
442
  throw new CardEncryptedError(rec.psp);
442
443
  if (!SUPPORTED_MODES.includes(mode))
@@ -15,7 +15,7 @@ export declare class PreparationGate {
15
15
  constructor(opts: AttachOptions, lifecycle: CheckoutLifecycle, readDocumentUrl: () => Promise<string>);
16
16
  private prepare;
17
17
  /** Called for every recognized card mutation, before any await or local retry guard. */
18
- claim(requestUrl: string): PreparedCheckout | undefined;
18
+ claim(requestUrl: string, requestBody?: string | null): PreparedCheckout | undefined;
19
19
  assertDocument(): Promise<void>;
20
20
  private readDocument;
21
21
  isEngaged(): boolean;
@@ -1,4 +1,5 @@
1
1
  import { CheckoutPreparationError } from './client.js';
2
+ import { matchesPreparedRequest, preparationEndpoint } from './prepared-processor.js';
2
3
  /** A local, one-use rendezvous. It never starts or retries a merchant request. */
3
4
  export class PreparationGate {
4
5
  opts;
@@ -33,9 +34,9 @@ export class PreparationGate {
33
34
  const onAbort = () => this.invalidate('cancelled');
34
35
  signal.addEventListener('abort', onAbort, { once: true });
35
36
  try {
36
- if (!options || options.psp !== 'square' || !['production', 'sandbox'].includes(options.environment))
37
+ if (!options || !['square', 'braintree'].includes(options.psp) || !['production', 'sandbox'].includes(options.environment))
37
38
  throw new CheckoutPreparationError(null, 'unsupported_processor');
38
- const tokenizer = options.environment === 'production' ? 'https://pci-connect.squareup.com/v2/card-nonce' : 'https://pci-connect.squareupsandbox.com/v2/card-nonce';
39
+ const tokenizer = preparationEndpoint(options.psp, options.environment);
39
40
  if (!this.opts.vault.isCardRequest(tokenizer, 'POST'))
40
41
  throw new CheckoutPreparationError(null, 'processor_interception_unavailable');
41
42
  if (!Number.isSafeInteger(this.opts.amountCents) || (this.opts.amountCents ?? 0) <= 0 || !/^[a-z]{3}$/i.test(this.opts.currency ?? ''))
@@ -62,6 +63,8 @@ export class PreparationGate {
62
63
  },
63
64
  });
64
65
  this.prepared = prepared;
66
+ if (prepared.psp !== options.psp || prepared.environment !== options.environment)
67
+ throw new CheckoutPreparationError(prepared.id, 'checkout_changed');
65
68
  if (signal.aborted || this.state !== 'preparing') {
66
69
  void this.opts.vault.cancelPreparation(prepared.id).catch(() => { });
67
70
  throw new CheckoutPreparationError(prepared.id, 'cancelled');
@@ -86,7 +89,7 @@ export class PreparationGate {
86
89
  // Keep the signal listener after ready: caller cancellation retires the handle too.
87
90
  }
88
91
  /** Called for every recognized card mutation, before any await or local retry guard. */
89
- claim(requestUrl) {
92
+ claim(requestUrl, requestBody) {
90
93
  this.observedRequest = true;
91
94
  if (this.state === 'unused')
92
95
  return undefined;
@@ -95,9 +98,7 @@ export class PreparationGate {
95
98
  throw new CheckoutPreparationError(this.prepared?.id ?? null, 'already_used_or_unavailable');
96
99
  }
97
100
  const prepared = this.prepared;
98
- const request = new URL(requestUrl);
99
- const origin = prepared.environment === 'production' ? 'https://pci-connect.squareup.com' : 'https://pci-connect.squareupsandbox.com';
100
- if (Date.parse(prepared.expiresAt) <= Date.now() || request.origin !== origin || request.pathname !== '/v2/card-nonce' || request.username || request.password) {
101
+ if (Date.parse(prepared.expiresAt) <= Date.now() || !matchesPreparedRequest(prepared.psp, prepared.environment, requestUrl, 'POST', requestBody)) {
101
102
  const reason = Date.parse(prepared.expiresAt) <= Date.now() ? 'expired' : 'checkout_changed';
102
103
  this.invalidate(reason);
103
104
  throw new CheckoutPreparationError(prepared.id, reason);
@@ -0,0 +1,5 @@
1
+ export type PreparationProcessor = 'square' | 'braintree';
2
+ export type PreparationEnvironment = 'production' | 'sandbox';
3
+ export declare function preparationEndpoint(psp: PreparationProcessor, environment: PreparationEnvironment): string;
4
+ /** Processor identity and environment are part of the device's prior consent. */
5
+ export declare function matchesPreparedRequest(psp: PreparationProcessor, environment: PreparationEnvironment, requestUrl: string, method: string, body?: string | null): boolean;
@@ -0,0 +1,25 @@
1
+ import { braintreeEnvironment, isPreparedBraintreeRequest } from './braintree.js';
2
+ export function preparationEndpoint(psp, environment) {
3
+ if (psp === 'braintree')
4
+ return environment === 'production'
5
+ ? 'https://payments.braintree-api.com/graphql' : 'https://payments.sandbox.braintree-api.com/graphql';
6
+ return environment === 'production'
7
+ ? 'https://pci-connect.squareup.com/v2/card-nonce' : 'https://pci-connect.squareupsandbox.com/v2/card-nonce';
8
+ }
9
+ /** Processor identity and environment are part of the device's prior consent. */
10
+ export function matchesPreparedRequest(psp, environment, requestUrl, method, body) {
11
+ if (method.toUpperCase() !== 'POST')
12
+ return false;
13
+ if (psp === 'braintree') {
14
+ return braintreeEnvironment(requestUrl) === environment && isPreparedBraintreeRequest(body ?? null);
15
+ }
16
+ if (psp !== 'square')
17
+ return false;
18
+ try {
19
+ const request = new URL(requestUrl), endpoint = new URL(preparationEndpoint(psp, environment));
20
+ return request.origin === endpoint.origin && request.pathname === endpoint.pathname && !request.username && !request.password;
21
+ }
22
+ catch {
23
+ return false;
24
+ }
25
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-cards/checkout",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Let browser agents pay with the user's own card, without your infrastructure ever touching card data.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -23,7 +23,7 @@
23
23
  "_comment_build": "TypeScript is fetched rather than declared as a devDependency ON PURPOSE. This package ships zero dependencies, which is why pnpm writes no importer for it in the workspace lockfile; adding any dep here creates one, and an importer the lockfile has not been regenerated for fails every Vercel build with ERR_PNPM_OUTDATED_LOCKFILE. Pinned so the published output is reproducible.",
24
24
  "build": "npx -y -p typescript@5.9.3 tsc",
25
25
  "prepublishOnly": "pnpm build",
26
- "test": "node test.mjs && node --test lifecycle.test.mjs merchant-abort.test.mjs preparation.test.mjs",
26
+ "test": "node test.mjs && node --test lifecycle.test.mjs merchant-abort.test.mjs preparation.test.mjs braintree.test.mjs minimum-delay.test.mjs",
27
27
  "test:browser": "node browser.test.mjs && node stripe-browser.test.mjs && node preparation-browser.test.mjs"
28
28
  },
29
29
  "keywords": [