@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 +7 -0
- package/README.md +10 -8
- package/dist/braintree.d.ts +8 -0
- package/dist/braintree.js +290 -0
- package/dist/cdp.js +24 -2
- package/dist/client.d.ts +3 -2
- package/dist/client.js +6 -5
- package/dist/preparation.d.ts +1 -1
- package/dist/preparation.js +7 -6
- package/dist/prepared-processor.d.ts +5 -0
- package/dist/prepared-processor.js +25 -0
- package/package.json +2 -2
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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 !==
|
|
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
|
-
||
|
|
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))
|
package/dist/preparation.d.ts
CHANGED
|
@@ -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;
|
package/dist/preparation.js
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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": [
|