@agent-cards/checkout 0.4.1 → 0.6.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 +19 -0
- package/README.md +33 -11
- package/dist/attachment.d.ts +11 -0
- package/dist/attachment.js +50 -0
- package/dist/braintree.d.ts +10 -0
- package/dist/braintree.js +302 -0
- package/dist/cdp.d.ts +2 -0
- package/dist/cdp.js +262 -147
- package/dist/client.d.ts +5 -4
- package/dist/client.js +6 -5
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/preparation.d.ts +1 -1
- package/dist/preparation.js +7 -6
- package/dist/prepared-processor.d.ts +7 -0
- package/dist/prepared-processor.js +93 -0
- package/dist/registry.js +10 -6
- package/package.json +2 -2
package/dist/preparation.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CheckoutPreparationError } from './client.js';
|
|
2
|
+
import { matchesPreparedRequest, validPreparationEnvironment, 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 || !validPreparationEnvironment(options.psp, 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,7 @@
|
|
|
1
|
+
export type PreparationProcessor = 'square' | 'braintree' | 'worldpay' | 'bambora' | 'mercado_pago';
|
|
2
|
+
export type PreparationEnvironment = 'production' | 'sandbox' | 'shared';
|
|
3
|
+
/** Shared endpoint processors cannot attest test/live mode from their URL or key prefix. */
|
|
4
|
+
export declare function validPreparationEnvironment(psp: string, environment: string): boolean;
|
|
5
|
+
export declare function preparationEndpoint(psp: PreparationProcessor, environment: PreparationEnvironment): string;
|
|
6
|
+
/** Processor identity and environment are part of the device's prior consent. */
|
|
7
|
+
export declare function matchesPreparedRequest(psp: PreparationProcessor, environment: PreparationEnvironment, requestUrl: string, method: string, body?: string | null): boolean;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { braintreeEnvironment, isPreparedBraintreeRequest, readTokenizationJson } from './braintree.js';
|
|
2
|
+
/** Shared endpoint processors cannot attest test/live mode from their URL or key prefix. */
|
|
3
|
+
export function validPreparationEnvironment(psp, environment) {
|
|
4
|
+
if (psp === 'bambora' || psp === 'mercado_pago')
|
|
5
|
+
return environment === 'shared';
|
|
6
|
+
return ['square', 'braintree', 'worldpay'].includes(psp) && ['production', 'sandbox'].includes(environment);
|
|
7
|
+
}
|
|
8
|
+
export function preparationEndpoint(psp, environment) {
|
|
9
|
+
if (!validPreparationEnvironment(psp, environment))
|
|
10
|
+
throw new Error('unsupported_preparation_processor');
|
|
11
|
+
if (psp === 'bambora')
|
|
12
|
+
return 'https://api.bam.shift4api.net/scripts/tokenization/tokens';
|
|
13
|
+
if (psp === 'mercado_pago')
|
|
14
|
+
return 'https://api.mercadopago.com/v1/card_tokens';
|
|
15
|
+
if (psp === 'worldpay')
|
|
16
|
+
return environment === 'production'
|
|
17
|
+
? 'https://access.worldpay.com/sessions/card' : 'https://try.access.worldpay.com/sessions/card';
|
|
18
|
+
if (psp === 'braintree')
|
|
19
|
+
return environment === 'production'
|
|
20
|
+
? 'https://payments.braintree-api.com/graphql' : 'https://payments.sandbox.braintree-api.com/graphql';
|
|
21
|
+
return environment === 'production'
|
|
22
|
+
? 'https://pci-connect.squareup.com/v2/card-nonce' : 'https://pci-connect.squareupsandbox.com/v2/card-nonce';
|
|
23
|
+
}
|
|
24
|
+
function record(value) {
|
|
25
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
26
|
+
}
|
|
27
|
+
const keys = (value, allowed) => Object.keys(value).every(key => allowed.includes(key));
|
|
28
|
+
const string = (value, max = 1024) => typeof value === 'string' && value.length > 0 && value.length <= max && !/[\u0000-\u001f\u007f]/.test(value);
|
|
29
|
+
const digits = (value, min, max) => typeof value === 'string' && value.length >= min && value.length <= max && !/[^0-9]/.test(value);
|
|
30
|
+
const month = (value) => (typeof value === 'number' && Number.isInteger(value) || digits(value, 1, 2)) && Number(value) >= 1 && Number(value) <= 12;
|
|
31
|
+
const year = (value) => (typeof value === 'number' && Number.isInteger(value) || digits(value, 2, 4)) && (Number(value) >= 0 && Number(value) <= 99 || Number(value) >= 2000 && Number(value) <= 9999);
|
|
32
|
+
/** Native fresh-card shapes only. Saved-card, charge and recurring siblings never consume consent. */
|
|
33
|
+
function freshCardBody(psp, body) {
|
|
34
|
+
const value = readTokenizationJson(body);
|
|
35
|
+
if (!value)
|
|
36
|
+
return false;
|
|
37
|
+
if (psp === 'worldpay')
|
|
38
|
+
return keys(value, ['identity', 'cardNumber', 'cardExpiryDate', 'cvc'])
|
|
39
|
+
&& string(value.identity) && digits(value.cardNumber, 12, 19)
|
|
40
|
+
&& record(value.cardExpiryDate) && keys(value.cardExpiryDate, ['month', 'year'])
|
|
41
|
+
&& month(value.cardExpiryDate.month) && year(value.cardExpiryDate.year)
|
|
42
|
+
&& (value.cvc === undefined || digits(value.cvc, 3, 4));
|
|
43
|
+
if (psp === 'bambora')
|
|
44
|
+
return keys(value, ['number', 'expiry_month', 'expiry_year', 'cvd'])
|
|
45
|
+
&& digits(value.number, 12, 19) && month(value.expiry_month) && year(value.expiry_year)
|
|
46
|
+
&& (value.cvd === undefined || digits(value.cvd, 3, 4));
|
|
47
|
+
if (psp !== 'mercado_pago' || !keys(value, ['card_number', 'expiration_month', 'expiration_year', 'security_code', 'cardholder', 'device'])
|
|
48
|
+
|| !digits(value.card_number, 12, 19) || !month(value.expiration_month) || !year(value.expiration_year)
|
|
49
|
+
|| (value.security_code !== undefined && value.security_code !== '' && !digits(value.security_code, 3, 4)) || !record(value.cardholder)
|
|
50
|
+
|| !keys(value.cardholder, ['name', 'identification']))
|
|
51
|
+
return false;
|
|
52
|
+
const holder = value.cardholder;
|
|
53
|
+
if (holder.name !== undefined && holder.name !== '' && !string(holder.name))
|
|
54
|
+
return false;
|
|
55
|
+
if (holder.identification !== undefined && (!record(holder.identification) || !keys(holder.identification, ['type', 'number'])
|
|
56
|
+
|| Object.values(holder.identification).some(v => v !== '' && !string(v))))
|
|
57
|
+
return false;
|
|
58
|
+
if (value.device !== undefined && (!record(value.device) || !keys(value.device, ['meli'])
|
|
59
|
+
|| !record(value.device.meli) || !keys(value.device.meli, ['session_id']) || !string(value.device.meli.session_id, 4096)))
|
|
60
|
+
return false;
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
/** Processor identity and environment are part of the device's prior consent. */
|
|
64
|
+
export function matchesPreparedRequest(psp, environment, requestUrl, method, body) {
|
|
65
|
+
if (method.toUpperCase() !== 'POST' || !validPreparationEnvironment(psp, environment))
|
|
66
|
+
return false;
|
|
67
|
+
if (psp === 'braintree')
|
|
68
|
+
return braintreeEnvironment(requestUrl) === environment && isPreparedBraintreeRequest(body ?? null);
|
|
69
|
+
try {
|
|
70
|
+
const request = new URL(requestUrl), endpoint = new URL(preparationEndpoint(psp, environment));
|
|
71
|
+
if (request.username || request.password || request.hash)
|
|
72
|
+
return false;
|
|
73
|
+
if (psp === 'bambora') {
|
|
74
|
+
if (![endpoint.href, 'https://api.na.bambora.com/scripts/tokenization/tokens'].includes(requestUrl))
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
else if (psp === 'worldpay') {
|
|
78
|
+
if (requestUrl !== endpoint.href)
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
else if (request.origin !== endpoint.origin || request.pathname !== endpoint.pathname)
|
|
82
|
+
return false;
|
|
83
|
+
if (psp === 'mercado_pago') {
|
|
84
|
+
const names = [...request.searchParams.keys()];
|
|
85
|
+
if (new Set(names).size !== names.length || names.some(name => !['public_key', 'locale', 'js_version', 'referer'].includes(name)))
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
return psp === 'square' || freshCardBody(psp, body ?? null);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
package/dist/registry.js
CHANGED
|
@@ -48,8 +48,12 @@ export const BUILTIN_REGISTRY = [
|
|
|
48
48
|
},
|
|
49
49
|
{
|
|
50
50
|
psp: 'checkout_com',
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
// Flow's native card controller can use either tokenization host.
|
|
52
|
+
match: /(api|card-acquisition-gateway)(\.sandbox)?\.checkout\.com\/tokens/i,
|
|
53
|
+
hosts: [
|
|
54
|
+
"^api\\.checkout\\.com$", "^api\\.sandbox\\.checkout\\.com$",
|
|
55
|
+
"^card-acquisition-gateway\\.checkout\\.com$", "^card-acquisition-gateway\\.sandbox\\.checkout\\.com$",
|
|
56
|
+
],
|
|
53
57
|
encoding: 'json',
|
|
54
58
|
passthroughHeaders: [/^authorization$/i],
|
|
55
59
|
},
|
|
@@ -156,10 +160,10 @@ export const BUILTIN_REGISTRY = [
|
|
|
156
160
|
},
|
|
157
161
|
{
|
|
158
162
|
psp: 'paysafe',
|
|
159
|
-
match: /api(\.test)?\.paysafe\.com\/(?:paymenthub\/v1\/singleusepaymenthandles|js\/api\/v1\/tokenize)(?![\w\/-])/i,
|
|
160
|
-
hosts: ["^api\\.paysafe\\.com$", "^api\\.test\\.paysafe\\.com$"],
|
|
163
|
+
match: /(?:api(\.test)?\.paysafe\.com\/(?:paymenthub\/v1\/singleusepaymenthandles|js\/api\/v1\/tokenize)|hosted(\.test)?\.paysafe\.com\/checkout\/api\/v1\/tokenize)(?![\w\/-])/i,
|
|
164
|
+
hosts: ["^api\\.paysafe\\.com$", "^api\\.test\\.paysafe\\.com$", "^hosted\\.paysafe\\.com$", "^hosted\\.test\\.paysafe\\.com$"],
|
|
161
165
|
encoding: 'json',
|
|
162
|
-
passthroughHeaders: [/^authorization$/i, /^x-paysafe-credentials$/i],
|
|
166
|
+
passthroughHeaders: [/^authorization$/i, /^x-paysafe-credentials$/i, /^correlationid$/i],
|
|
163
167
|
},
|
|
164
168
|
{
|
|
165
169
|
psp: 'recurly',
|
|
@@ -211,7 +215,7 @@ export function findRecognizer(url, registry = BUILTIN_REGISTRY) {
|
|
|
211
215
|
catch {
|
|
212
216
|
return null;
|
|
213
217
|
}
|
|
214
|
-
if (parsed.protocol !== 'https:')
|
|
218
|
+
if (parsed.protocol !== 'https:' || parsed.port || parsed.username || parsed.password)
|
|
215
219
|
return null;
|
|
216
220
|
const hostPath = `${parsed.hostname.toLowerCase()}${parsed.pathname}`;
|
|
217
221
|
return (registry.find((r) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-cards/checkout",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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 prepared-processor.test.mjs minimum-delay.test.mjs paysafe.test.mjs attachment.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": [
|