@agent-cards/checkout 0.4.0 → 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 +13 -0
- package/README.md +23 -13
- package/dist/braintree.d.ts +8 -0
- package/dist/braintree.js +290 -0
- package/dist/cdp.js +33 -4
- package/dist/client.d.ts +18 -10
- package/dist/client.js +40 -15
- package/dist/index.d.ts +1 -1
- package/dist/lifecycle.js +9 -2
- 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,18 @@
|
|
|
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
|
+
|
|
10
|
+
## 0.4.1
|
|
11
|
+
|
|
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.
|
|
13
|
+
- Hold further card requests within the attachment after a processor refusal, including when the merchant retries after the approval cooldown. Reconcile the merchant payment; only an explicit `retryAfterMerchantFailure({ status: 'failed' })` releases this hold and permits an immediate new attempt. This also applies to clear card declines. Ordinary user declines keep their approval cooldown. Do not automatically replace the attachment to bypass reconciliation.
|
|
14
|
+
- Add deterministic refusal, diagnostic sanitization and retry-guard regressions. These tests do not establish a successful Razorpay purchase or processor capture.
|
|
15
|
+
|
|
3
16
|
## 0.4.0
|
|
4
17
|
|
|
5
18
|
- Add `controller.prepare({ psp: 'square', environment: 'production' | 'sandbox' })` to both browser adapters. The caller awaits cardholder consent and unlock before starting its first native Pay action. This requires the matching preparation API and Vault deployment.
|
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 |
|
|
@@ -248,11 +248,19 @@ one. `amountAuthority` on every replay is `stripe_payment_intent`,
|
|
|
248
248
|
refused to replay a confirm at it. An `ApprovalDeclinedError` with
|
|
249
249
|
`code: 'intent_not_confirmable'`. Deliberately not "nothing was charged":
|
|
250
250
|
check the intent at Stripe before retrying.
|
|
251
|
-
- `ProcessorRefusedError`: the cardholder's device
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
251
|
+
- `ProcessorRefusedError`: the cardholder's device reported a processor
|
|
252
|
+
request rejection. `pspErrorCode` carries the processor's code; optional
|
|
253
|
+
`processorError` carries bounded Razorpay reason, source, step and payment/order
|
|
254
|
+
identifiers when the API has them. A generic code such as `BAD_REQUEST_ERROR`
|
|
255
|
+
does not establish an issuer decline or prove no money moved. Reconcile the
|
|
256
|
+
merchant payment before retrying. This remains an `ApprovalDeclinedError`
|
|
257
|
+
with `code: 'processor_refused'` for compatibility.
|
|
258
|
+
The attachment records `status: 'declined', reason: 'processor_refused'`
|
|
259
|
+
and holds further card requests. After confirming merchant failure, call
|
|
260
|
+
`retryAfterMerchantFailure({ status: 'failed' })` to permit a deliberate new
|
|
261
|
+
attempt immediately, without waiting for the user-decline cooldown. Do not
|
|
262
|
+
automatically create a new attachment after this error;
|
|
263
|
+
the guard applies only within the existing attachment.
|
|
256
264
|
- `CheckoutApiError` with `code === 'amount_unverifiable'`: Stripe could not
|
|
257
265
|
be asked (502; the SDK retries twice, 500ms then 1500ms, before throwing)
|
|
258
266
|
or the paused request lacked its client secret or publishable key (400).
|
|
@@ -392,7 +400,7 @@ do not reuse that token in a new attachment as a workaround. Configuration and
|
|
|
392
400
|
unsupported-mode failures require fixing the integration. Bank flows requiring
|
|
393
401
|
another confirmation and other stored-token chains remain unverified.
|
|
394
402
|
|
|
395
|
-
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:
|
|
396
404
|
|
|
397
405
|
```ts
|
|
398
406
|
const checkout = await attachToPlaywright(page, {
|
|
@@ -401,19 +409,21 @@ const checkout = await attachToPlaywright(page, {
|
|
|
401
409
|
onApprovalUrl: deliverPrivatelyToCardholder,
|
|
402
410
|
});
|
|
403
411
|
const preparation = await checkout.prepare({
|
|
404
|
-
psp: 'square'
|
|
405
|
-
environment: 'production', // explicit; use 'sandbox' for
|
|
412
|
+
psp: 'braintree', // use 'square' for Square
|
|
413
|
+
environment: 'production', // explicit; use 'sandbox' for the processor's sandbox
|
|
406
414
|
});
|
|
407
415
|
// The cardholder has consented and unlocked the same approval document.
|
|
408
416
|
// No processor request or payment has started.
|
|
409
417
|
await page.getByRole('button', { name: 'Pay', exact: true }).click();
|
|
410
418
|
```
|
|
411
419
|
|
|
412
|
-
`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.
|
|
413
421
|
|
|
414
|
-
|
|
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.
|
|
415
423
|
|
|
416
|
-
|
|
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.
|
|
417
427
|
|
|
418
428
|
Lost authorization polling, local approval timeouts, or interrupted browser
|
|
419
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
|
-
import { ApprovalDeclinedError, ApprovalTimeoutError, CardEncryptedError, CheckoutApiError, PaymentOutcomeUnknownError, UnsupportedModeError, redactUrl, } from './client.js';
|
|
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';
|
|
@@ -80,6 +81,11 @@ const APPROVAL_COOLDOWN_MS = 5_000;
|
|
|
80
81
|
* quiet window absorbs the burst; the next request past it is judged afresh.
|
|
81
82
|
*/
|
|
82
83
|
function isApprovalOutcome(err) {
|
|
84
|
+
// Processor refusals are held by the lifecycle until explicit merchant
|
|
85
|
+
// reconciliation and retry. A second timer would outlive that release and
|
|
86
|
+
// reject the application's deliberate retry even after confirmed failure.
|
|
87
|
+
if (err instanceof ProcessorRefusedError)
|
|
88
|
+
return false;
|
|
83
89
|
if (err instanceof CheckoutApiError)
|
|
84
90
|
return err.code === 'duplicate_submission';
|
|
85
91
|
return err instanceof ApprovalDeclinedError || err instanceof ApprovalTimeoutError;
|
|
@@ -98,7 +104,9 @@ function isApprovalOutcome(err) {
|
|
|
98
104
|
* a misconfiguration or an unsupported PSP. Nothing a person does changes
|
|
99
105
|
* those, so asking again is pure waste.
|
|
100
106
|
*
|
|
101
|
-
*
|
|
107
|
+
* Other errors may be retryable unless the lifecycle holds the attachment
|
|
108
|
+
* for merchant reconciliation, as it does after a processor refusal.
|
|
109
|
+
* A 5xx or a 429 clears on its own, and a
|
|
102
110
|
* decline or a timeout is answered by the cooldown above rather than by
|
|
103
111
|
* killing the page: the person said no to one authorization, not to every
|
|
104
112
|
* checkout they will ever make in this session. A 409 duplicate_submission
|
|
@@ -406,6 +414,19 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
406
414
|
if (method !== 'Fetch.requestPaused')
|
|
407
415
|
return;
|
|
408
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
|
+
}
|
|
409
430
|
if (!opts.vault.isCardRequest(request.url, request.method)) {
|
|
410
431
|
if (guards.matches(request.url, request.method)) {
|
|
411
432
|
preparationGate.invalidate('unsupported_checkout');
|
|
@@ -419,7 +440,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
419
440
|
}
|
|
420
441
|
let preparation;
|
|
421
442
|
try {
|
|
422
|
-
preparation = preparationGate.claim(request.url);
|
|
443
|
+
preparation = preparationGate.claim(request.url, braintree === 'tokenization' ? pausedBody(request) : undefined);
|
|
423
444
|
}
|
|
424
445
|
catch (error) {
|
|
425
446
|
opts.onEvent?.({ type: 'blocked', detail: failureSummary(error) });
|
|
@@ -639,6 +660,14 @@ export async function attachToPlaywright(page, opts) {
|
|
|
639
660
|
let lastSubmitted = null;
|
|
640
661
|
await page.route((url) => opts.vault.isCardRequest(url.toString()) || guards.matches(url.toString()), async (route) => {
|
|
641
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
|
+
}
|
|
642
671
|
// The matcher only sees the URL; a preflight or a GET must pass through
|
|
643
672
|
// untouched or the browser's CORS check fails on our synthetic answer.
|
|
644
673
|
if (!opts.vault.isCardRequest(request.url(), request.method())) {
|
|
@@ -652,7 +681,7 @@ export async function attachToPlaywright(page, opts) {
|
|
|
652
681
|
}
|
|
653
682
|
let preparation;
|
|
654
683
|
try {
|
|
655
|
-
preparation = preparationGate.claim(request.url());
|
|
684
|
+
preparation = preparationGate.claim(request.url(), braintree === 'tokenization' ? request.postData() ?? '' : undefined);
|
|
656
685
|
}
|
|
657
686
|
catch (error) {
|
|
658
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;
|
|
@@ -271,20 +272,27 @@ export declare class IntentNotConfirmableError extends ApprovalDeclinedError {
|
|
|
271
272
|
readonly code: "intent_not_confirmable";
|
|
272
273
|
constructor(authorizationId: string);
|
|
273
274
|
}
|
|
275
|
+
/** Bounded processor identifiers, never a raw response or free-form description. */
|
|
276
|
+
export interface RazorpayProcessorError {
|
|
277
|
+
reason?: string;
|
|
278
|
+
source?: string;
|
|
279
|
+
step?: string;
|
|
280
|
+
payment_id?: string;
|
|
281
|
+
order_id?: string;
|
|
282
|
+
}
|
|
274
283
|
/**
|
|
275
|
-
* The
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
* request is aborted and the page's retry gets a fresh approval, where the
|
|
281
|
-
* person can pick another card.
|
|
284
|
+
* The device reported a processor request rejection. A generic processor
|
|
285
|
+
* code does not establish an issuer decline or prove no money moved. Read
|
|
286
|
+
* the bounded processor evidence and reconcile the merchant before retrying.
|
|
287
|
+
* The authorization remains `declined` with reason `processor_refused` for
|
|
288
|
+
* compatibility; no successful processor response is handed to the merchant.
|
|
282
289
|
*/
|
|
283
290
|
export declare class ProcessorRefusedError extends ApprovalDeclinedError {
|
|
284
291
|
authorizationId: string;
|
|
285
292
|
pspErrorCode: string | null;
|
|
286
293
|
readonly code: "processor_refused";
|
|
287
|
-
|
|
294
|
+
readonly processorError: RazorpayProcessorError | null;
|
|
295
|
+
constructor(authorizationId: string, pspErrorCode: string | null, processorError?: RazorpayProcessorError | null);
|
|
288
296
|
}
|
|
289
297
|
/**
|
|
290
298
|
* A non-2xx from the Agentcard API, carrying the status so callers can tell a
|
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
|
|
@@ -128,25 +129,49 @@ export class IntentNotConfirmableError extends ApprovalDeclinedError {
|
|
|
128
129
|
+ 'it cannot be confirmed again. Check the intent at Stripe before retrying.';
|
|
129
130
|
}
|
|
130
131
|
}
|
|
132
|
+
function parseProcessorError(value) {
|
|
133
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
134
|
+
return null;
|
|
135
|
+
const fields = value;
|
|
136
|
+
const result = {};
|
|
137
|
+
const exact = (pattern, field) => pattern.exec(field)?.[0] === field;
|
|
138
|
+
for (const [key, field] of Object.entries(fields)) {
|
|
139
|
+
if (typeof field !== 'string')
|
|
140
|
+
return null;
|
|
141
|
+
if (key === 'reason' || key === 'source' || key === 'step') {
|
|
142
|
+
if (!exact(/^[a-z][a-z_]{0,63}$/, field))
|
|
143
|
+
return null;
|
|
144
|
+
result[key] = field;
|
|
145
|
+
}
|
|
146
|
+
else if (key === 'payment_id' || key === 'order_id') {
|
|
147
|
+
if (!exact(key === 'payment_id' ? /^pay_[A-Za-z0-9]{1,128}$/ : /^order_[A-Za-z0-9]{1,128}$/, field))
|
|
148
|
+
return null;
|
|
149
|
+
result[key] = field;
|
|
150
|
+
}
|
|
151
|
+
else
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
return Object.keys(result).length ? result : null;
|
|
155
|
+
}
|
|
131
156
|
/**
|
|
132
|
-
* The
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
* request is aborted and the page's retry gets a fresh approval, where the
|
|
138
|
-
* person can pick another card.
|
|
157
|
+
* The device reported a processor request rejection. A generic processor
|
|
158
|
+
* code does not establish an issuer decline or prove no money moved. Read
|
|
159
|
+
* the bounded processor evidence and reconcile the merchant before retrying.
|
|
160
|
+
* The authorization remains `declined` with reason `processor_refused` for
|
|
161
|
+
* compatibility; no successful processor response is handed to the merchant.
|
|
139
162
|
*/
|
|
140
163
|
export class ProcessorRefusedError extends ApprovalDeclinedError {
|
|
141
164
|
authorizationId;
|
|
142
165
|
pspErrorCode;
|
|
143
166
|
code = 'processor_refused';
|
|
144
|
-
|
|
167
|
+
processorError;
|
|
168
|
+
constructor(authorizationId, pspErrorCode, processorError = null) {
|
|
145
169
|
super('processor_refused');
|
|
146
170
|
this.authorizationId = authorizationId;
|
|
147
171
|
this.pspErrorCode = pspErrorCode;
|
|
148
172
|
this.name = 'ProcessorRefusedError';
|
|
149
|
-
this.
|
|
173
|
+
this.processorError = parseProcessorError(processorError);
|
|
174
|
+
this.message = `the processor rejected the payment request on ${authorizationId}${pspErrorCode ? ` (${pspErrorCode})` : ''}. Check the merchant payment status before retrying.`;
|
|
150
175
|
}
|
|
151
176
|
}
|
|
152
177
|
/**
|
|
@@ -290,7 +315,7 @@ export class VaultClient {
|
|
|
290
315
|
async prepareCheckout(input) {
|
|
291
316
|
input = { ...input };
|
|
292
317
|
const fail = (reason, id = null) => new CheckoutPreparationError(id, reason);
|
|
293
|
-
if (input.psp
|
|
318
|
+
if (!['square', 'braintree'].includes(input.psp) || !['production', 'sandbox'].includes(input.environment))
|
|
294
319
|
throw fail('unsupported_processor');
|
|
295
320
|
if (!Number.isSafeInteger(input.amountCents) || input.amountCents <= 0 || typeof input.currency !== 'string' || !/^[a-z]{3}$/i.test(input.currency))
|
|
296
321
|
throw fail('amount_required');
|
|
@@ -343,7 +368,7 @@ export class VaultClient {
|
|
|
343
368
|
|| state.payment_status !== 'not_started' || state.amount_authority !== 'display_only'
|
|
344
369
|
|| state.user !== input.user || state.merchant !== input.merchant || state.merchant_origin !== input.merchantOrigin
|
|
345
370
|
|| state.amount_cents !== input.amountCents || state.currency !== input.currency.toLowerCase()
|
|
346
|
-
|| state.psp !==
|
|
371
|
+
|| state.psp !== input.psp || state.mode !== 'token' || state.environment !== input.environment
|
|
347
372
|
|| state.checkout_key !== input.checkoutKey)
|
|
348
373
|
throw fail('ready_unconfirmed', id);
|
|
349
374
|
const prepared = Object.freeze({
|
|
@@ -392,13 +417,11 @@ export class VaultClient {
|
|
|
392
417
|
throw new CheckoutPreparationError(preparation.id ?? null, 'already_used_or_foreign');
|
|
393
418
|
// Consume locally before any await, including OAuth, and never recycle it.
|
|
394
419
|
this.usedPreparations.add(preparation);
|
|
395
|
-
const url = new URL(input.request.url);
|
|
396
|
-
const host = preparation.environment === 'production' ? 'pci-connect.squareup.com' : 'pci-connect.squareupsandbox.com';
|
|
397
420
|
if (Date.parse(preparation.expiresAt) <= Date.now())
|
|
398
421
|
throw new CheckoutPreparationError(preparation.id, 'expired');
|
|
399
422
|
if (input.user !== preparation.user || input.merchant !== preparation.merchant || input.amountCents !== preparation.amountCents
|
|
400
423
|
|| input.currency?.toLowerCase() !== preparation.currency || input.cardId !== preparation.cardId
|
|
401
|
-
||
|
|
424
|
+
|| !matchesPreparedRequest(preparation.psp, preparation.environment, input.request.url, input.request.method ?? 'POST', input.request.body))
|
|
402
425
|
throw new CheckoutPreparationError(preparation.id, 'checkout_changed');
|
|
403
426
|
}
|
|
404
427
|
if (input.signal?.aborted)
|
|
@@ -413,6 +436,8 @@ export class VaultClient {
|
|
|
413
436
|
// clientSideEncrypted entry (an older registry, a hand-built one) still
|
|
414
437
|
// refuses here, exactly as before.
|
|
415
438
|
const mode = rec.mode ?? 'token';
|
|
439
|
+
if (preparation && (rec.psp !== preparation.psp || mode !== 'token'))
|
|
440
|
+
throw new CheckoutPreparationError(preparation.id, 'checkout_changed');
|
|
416
441
|
if (rec.clientSideEncrypted && mode !== 'cse')
|
|
417
442
|
throw new CardEncryptedError(rec.psp);
|
|
418
443
|
if (!SUPPORTED_MODES.includes(mode))
|
|
@@ -611,7 +636,7 @@ export class VaultClient {
|
|
|
611
636
|
if (s.reason === 'intent_not_confirmable')
|
|
612
637
|
throw new IntentNotConfirmableError(String(created.id));
|
|
613
638
|
if (s.reason === 'processor_refused') {
|
|
614
|
-
throw new ProcessorRefusedError(String(created.id), typeof s.psp_error_code === 'string' ? s.psp_error_code : null);
|
|
639
|
+
throw new ProcessorRefusedError(String(created.id), typeof s.psp_error_code === 'string' ? s.psp_error_code : null, s.psp === 'razorpay' ? parseProcessorError(s.processor_error) : null);
|
|
615
640
|
}
|
|
616
641
|
throw new ApprovalDeclinedError(s.reason ?? 'no reason given');
|
|
617
642
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { VaultClient, CardEncryptedError, UnsupportedModeError, ApprovalTimeoutError, ApprovalDeclinedError, AmountMismatchError, IntentNotConfirmableError, ProcessorRefusedError, CheckoutApiError, redactUrl, SUPPORTED_MODES, PaymentOutcomeUnknownError, CheckoutCancelledError, CheckoutPreparationError, } from './client.js';
|
|
2
|
-
export type { PausedRequest, ReplayResponse, TokenReplay, CseReplay, HostedFormReplay, AmountAuthority, AuthorizeInput, VaultClientOptions, PrepareCheckoutOptions, PrepareCheckoutInput, PreparedCheckout, } from './client.js';
|
|
2
|
+
export type { PausedRequest, ReplayResponse, TokenReplay, CseReplay, HostedFormReplay, AmountAuthority, AuthorizeInput, VaultClientOptions, PrepareCheckoutOptions, PrepareCheckoutInput, PreparedCheckout, RazorpayProcessorError, } from './client.js';
|
|
3
3
|
export { attachToCdp, attachToPlaywright, corsHeadersFor, corsDecision, withCorsHeaders } from './cdp.js';
|
|
4
4
|
export type { CdpLike, AttachOptions, CorsOutcome } from './cdp.js';
|
|
5
5
|
export { substituteEncryptedFields, SubstitutionError } from './substitute.js';
|
package/dist/lifecycle.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ApprovalDeclinedError, ApprovalTimeoutError, CheckoutCancelledError, CheckoutPreparationError, IntentNotConfirmableError, PaymentOutcomeUnknownError } from './client.js';
|
|
1
|
+
import { ApprovalDeclinedError, ApprovalTimeoutError, CheckoutCancelledError, CheckoutPreparationError, IntentNotConfirmableError, PaymentOutcomeUnknownError, ProcessorRefusedError } from './client.js';
|
|
2
2
|
/** Shared by the raw CDP and Playwright transports. No browser ownership or payment execution lives here. */
|
|
3
3
|
export class CheckoutLifecycle {
|
|
4
4
|
options;
|
|
@@ -115,7 +115,7 @@ export class CheckoutLifecycle {
|
|
|
115
115
|
this.merchantRequestAborted();
|
|
116
116
|
return;
|
|
117
117
|
}
|
|
118
|
-
const authorizationId = error instanceof PaymentOutcomeUnknownError ? error.authorizationId : this.state.authorizationId;
|
|
118
|
+
const authorizationId = error instanceof PaymentOutcomeUnknownError || error instanceof ProcessorRefusedError ? error.authorizationId : this.state.authorizationId;
|
|
119
119
|
if (handoffStarted || error instanceof PaymentOutcomeUnknownError || error instanceof IntentNotConfirmableError) {
|
|
120
120
|
this.held = true;
|
|
121
121
|
this.set({ ...this.state, authorizationId, status: 'outcome_unknown', reason: error instanceof PaymentOutcomeUnknownError ? error.reason : error instanceof IntentNotConfirmableError ? 'intent_not_confirmable' : 'browser_handoff_failed' });
|
|
@@ -129,6 +129,13 @@ export class CheckoutLifecycle {
|
|
|
129
129
|
else if (error instanceof ApprovalTimeoutError) {
|
|
130
130
|
this.set({ ...this.state, status: 'timed_out', reason: 'approval_expired' });
|
|
131
131
|
}
|
|
132
|
+
else if (error instanceof ProcessorRefusedError) {
|
|
133
|
+
// A rejected processor request is not proof that the merchant cannot
|
|
134
|
+
// collect this payment. Keep native retries held until the application
|
|
135
|
+
// checks the merchant and explicitly starts another attempt.
|
|
136
|
+
this.held = true;
|
|
137
|
+
this.set({ ...this.state, authorizationId, status: 'declined', reason: 'processor_refused' });
|
|
138
|
+
}
|
|
132
139
|
else if (error instanceof ApprovalDeclinedError) {
|
|
133
140
|
this.set({ ...this.state, status: 'declined', reason: 'approval_declined' });
|
|
134
141
|
}
|
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": [
|