@agent-cards/checkout 0.1.0 → 0.2.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/README.md +160 -6
- package/dist/cdp.d.ts +24 -2
- package/dist/cdp.js +309 -26
- package/dist/client.d.ts +268 -3
- package/dist/client.js +360 -12
- package/dist/hosted-form.d.ts +44 -0
- package/dist/hosted-form.js +78 -0
- package/dist/index.d.ts +8 -4
- package/dist/index.js +4 -2
- package/dist/registry.d.ts +50 -1
- package/dist/registry.js +436 -4
- package/dist/substitute.d.ts +42 -0
- package/dist/substitute.js +87 -0
- package/package.json +1 -1
package/dist/registry.d.ts
CHANGED
|
@@ -7,11 +7,33 @@
|
|
|
7
7
|
* integrators pick up new processors without shipping a release.
|
|
8
8
|
*/
|
|
9
9
|
export type Encoding = 'json' | 'form';
|
|
10
|
+
/**
|
|
11
|
+
* How a paused request gets its card. `token` (the default when absent): the
|
|
12
|
+
* cardholder's device calls the processor and the runtime replays the
|
|
13
|
+
* processor's response. `cse`: the processor encrypts the card in-page
|
|
14
|
+
* (Adyen); the device returns ciphertext and the runtime substitutes it into
|
|
15
|
+
* the paused body and lets the request continue. `hosted_form`: the device
|
|
16
|
+
* submits the processor's own form and the paused request resolves with no
|
|
17
|
+
* processor answer (served only to SDKs that ask for it on sync).
|
|
18
|
+
*/
|
|
19
|
+
export type CheckoutMode = 'token' | 'cse' | 'hosted_form';
|
|
10
20
|
export interface Recognizer {
|
|
11
21
|
/** Stable id, e.g. "shopify" */
|
|
12
22
|
psp: string;
|
|
13
23
|
/** Matches the tokenization endpoint. */
|
|
14
24
|
match: RegExp;
|
|
25
|
+
/**
|
|
26
|
+
* Anchored (`^…$`) regex SOURCES for the hostnames this PSP may ever receive
|
|
27
|
+
* a card on — the server's own allowlist, served next to `match` and carried
|
|
28
|
+
* through syncRegistry() verbatim.
|
|
29
|
+
*
|
|
30
|
+
* Strings, not RegExps, because that is the wire shape and because nothing
|
|
31
|
+
* here matches with them: they exist so a transport that can only pre-filter
|
|
32
|
+
* by URL (CDP's `Fetch.enable`, which takes globs) can be armed from the
|
|
33
|
+
* registry instead of a hardcoded list. Optional so a hand-built registry
|
|
34
|
+
* (tests, pinning) still type-checks.
|
|
35
|
+
*/
|
|
36
|
+
hosts?: string[];
|
|
15
37
|
encoding: Encoding;
|
|
16
38
|
/**
|
|
17
39
|
* Request headers that must be replayed verbatim for the merchant to accept
|
|
@@ -21,9 +43,36 @@ export interface Recognizer {
|
|
|
21
43
|
/**
|
|
22
44
|
* True when the card is encrypted inside the page before the request leaves.
|
|
23
45
|
* A digit swap is useless here; the vault must re-run the PSP's client-side
|
|
24
|
-
* crypto instead.
|
|
46
|
+
* crypto instead. Refused locally unless the entry also carries
|
|
47
|
+
* `mode: 'cse'`, which says the vault does exactly that.
|
|
25
48
|
*/
|
|
26
49
|
clientSideEncrypted?: boolean;
|
|
50
|
+
/** How the card reaches the processor; absent means 'token'. Carried verbatim by syncRegistry. */
|
|
51
|
+
mode?: CheckoutMode;
|
|
27
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* Every PSP the service recognizes, shipped with the package.
|
|
55
|
+
*
|
|
56
|
+
* This list is the FALLBACK that decides interception whenever
|
|
57
|
+
* `syncRegistry()` is skipped or its fetch fails — and that failure is silent
|
|
58
|
+
* by design ("never break checkout over a registry fetch"). A PSP missing here
|
|
59
|
+
* is not an error: its tokenization request is simply never paused, the vault
|
|
60
|
+
* is never consulted, and the shopper pays with their OWN card.
|
|
61
|
+
*
|
|
62
|
+
* It shipped with only 5 of the service's 22 recognizers, which meant 17
|
|
63
|
+
* processors degraded to a silent wrong-card charge for any integrator whose
|
|
64
|
+
* sync had not landed. Generated from the backend registry
|
|
65
|
+
* (packages/backend/src/services/checkout-recognizers.ts) so the two cannot
|
|
66
|
+
* drift by transcription; `sdk-registry-drift.test.ts` fails CI if they do.
|
|
67
|
+
*
|
|
68
|
+
* Syncing still matters — it is how a NEW processor arrives without an npm
|
|
69
|
+
* release. This list is the floor, not the ceiling.
|
|
70
|
+
*/
|
|
28
71
|
export declare const BUILTIN_REGISTRY: Recognizer[];
|
|
29
72
|
export declare function findRecognizer(url: string, registry?: Recognizer[]): Recognizer | null;
|
|
73
|
+
/**
|
|
74
|
+
* Glob url patterns covering every host in `registry` — what to hand
|
|
75
|
+
* `Fetch.enable` so a card request is ever paused. Deliberately a superset of
|
|
76
|
+
* the recognizers; isCardRequest is the exact check.
|
|
77
|
+
*/
|
|
78
|
+
export declare function cardUrlPatterns(registry: Recognizer[]): string[];
|
package/dist/registry.js
CHANGED
|
@@ -6,40 +6,472 @@
|
|
|
6
6
|
* site shares another. The list is served from the Agentcard API at runtime so
|
|
7
7
|
* integrators pick up new processors without shipping a release.
|
|
8
8
|
*/
|
|
9
|
+
/**
|
|
10
|
+
* Every PSP the service recognizes, shipped with the package.
|
|
11
|
+
*
|
|
12
|
+
* This list is the FALLBACK that decides interception whenever
|
|
13
|
+
* `syncRegistry()` is skipped or its fetch fails — and that failure is silent
|
|
14
|
+
* by design ("never break checkout over a registry fetch"). A PSP missing here
|
|
15
|
+
* is not an error: its tokenization request is simply never paused, the vault
|
|
16
|
+
* is never consulted, and the shopper pays with their OWN card.
|
|
17
|
+
*
|
|
18
|
+
* It shipped with only 5 of the service's 22 recognizers, which meant 17
|
|
19
|
+
* processors degraded to a silent wrong-card charge for any integrator whose
|
|
20
|
+
* sync had not landed. Generated from the backend registry
|
|
21
|
+
* (packages/backend/src/services/checkout-recognizers.ts) so the two cannot
|
|
22
|
+
* drift by transcription; `sdk-registry-drift.test.ts` fails CI if they do.
|
|
23
|
+
*
|
|
24
|
+
* Syncing still matters — it is how a NEW processor arrives without an npm
|
|
25
|
+
* release. This list is the floor, not the ceiling.
|
|
26
|
+
*/
|
|
9
27
|
export const BUILTIN_REGISTRY = [
|
|
10
28
|
{
|
|
11
29
|
psp: 'shopify',
|
|
12
30
|
match: /(checkout\.pci\.shopifyinc\.com|deposit\.[a-z0-9-]+\.shopifycs\.com)\/sessions/i,
|
|
31
|
+
hosts: ["^checkout\\.pci\\.shopifyinc\\.com$", "^deposit\\.[a-z0-9-]+\\.shopifycs\\.com$"],
|
|
13
32
|
encoding: 'json',
|
|
14
33
|
passthroughHeaders: [/^shopify-identification-signature$/i],
|
|
15
34
|
},
|
|
16
35
|
{
|
|
17
36
|
psp: 'stripe',
|
|
18
|
-
match: /api\.stripe\.com\/v1\/(payment_methods|tokens)/i,
|
|
37
|
+
match: /api\.stripe\.com\/v1\/(payment_methods|tokens|setup_intents\/seti_\w+\/confirm|payment_intents\/pi_\w+\/confirm)/i,
|
|
38
|
+
hosts: ["^api\\.stripe\\.com$"],
|
|
19
39
|
encoding: 'form',
|
|
20
|
-
// Elements' own surface markers; without these Stripe rejects the surface.
|
|
21
40
|
passthroughHeaders: [/^authorization$/i, /^stripe-version$/i, /^x-stripe-client-user-agent$/i],
|
|
22
41
|
},
|
|
23
42
|
{
|
|
24
43
|
psp: 'braintree',
|
|
25
44
|
match: /payments(\.sandbox)?\.braintree-api\.com\/graphql/i,
|
|
45
|
+
hosts: ["^payments\\.braintree-api\\.com$", "^payments\\.sandbox\\.braintree-api\\.com$"],
|
|
26
46
|
encoding: 'json',
|
|
27
47
|
passthroughHeaders: [/^authorization$/i, /^braintree-version$/i],
|
|
28
48
|
},
|
|
29
49
|
{
|
|
30
50
|
psp: 'checkout_com',
|
|
31
51
|
match: /api(\.sandbox)?\.checkout\.com\/tokens/i,
|
|
52
|
+
hosts: ["^api\\.checkout\\.com$", "^api\\.sandbox\\.checkout\\.com$"],
|
|
32
53
|
encoding: 'json',
|
|
33
54
|
passthroughHeaders: [/^authorization$/i],
|
|
34
55
|
},
|
|
35
56
|
{
|
|
36
57
|
psp: 'adyen',
|
|
37
|
-
match: /(checkoutshopper-[a-z]
|
|
58
|
+
match: /(checkoutshopper-(test|live(-[a-z]+)?)\.adyen\.com|([a-z0-9-]+\.)*adyenpayments\.com)\/checkoutshopper\/v1\/sessions\/[A-Za-z0-9_-]+\/payments/i,
|
|
59
|
+
hosts: ["^checkoutshopper-(test|live(-[a-z]+)?)\\.adyen\\.com$", "^([a-z0-9-]+\\.)*adyenpayments\\.com$"],
|
|
38
60
|
encoding: 'json',
|
|
39
61
|
passthroughHeaders: [],
|
|
40
62
|
clientSideEncrypted: true,
|
|
63
|
+
mode: 'cse',
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
psp: 'tranzila',
|
|
67
|
+
match: /(direct\.tranzila\.com\/process\/n\/|directng\.tranzila\.com\/process\/?)/i,
|
|
68
|
+
hosts: ["^direct\\.tranzila\\.com$", "^directng\\.tranzila\\.com$"],
|
|
69
|
+
encoding: 'form',
|
|
70
|
+
passthroughHeaders: [],
|
|
71
|
+
mode: 'hosted_form',
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
psp: 'square',
|
|
75
|
+
match: /pci-connect\.squareup(?:sandbox)?\.com\/v2\/card-nonce/i,
|
|
76
|
+
hosts: ["^pci-connect\\.squareup\\.com$", "^pci-connect\\.squareupsandbox\\.com$"],
|
|
77
|
+
encoding: 'json',
|
|
78
|
+
passthroughHeaders: [],
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
psp: 'authorize_net',
|
|
82
|
+
match: /(api2|apitest)\.authorize\.net\/xml\/v1\/request\.api/i,
|
|
83
|
+
hosts: ["^api2\\.authorize\\.net$", "^apitest\\.authorize\\.net$"],
|
|
84
|
+
encoding: 'json',
|
|
85
|
+
passthroughHeaders: [],
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
psp: 'worldpay',
|
|
89
|
+
match: /(try\.)?access\.worldpay\.com\/(sessions\/card|verifiedTokens\/sessions)/i,
|
|
90
|
+
hosts: ["^access\\.worldpay\\.com$", "^try\\.access\\.worldpay\\.com$"],
|
|
91
|
+
encoding: 'json',
|
|
92
|
+
passthroughHeaders: [/^accept$/i],
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
psp: 'nuvei',
|
|
96
|
+
match: /(secure|ppp-test)\.safecharge\.com\/ppp\/api\/v1\/(?:cardTokenization|clientPayment|websdk\/initPaymentWithCardTokenization)\.do/i,
|
|
97
|
+
hosts: ["^secure\\.safecharge\\.com$", "^ppp-test\\.safecharge\\.com$"],
|
|
98
|
+
encoding: 'json',
|
|
99
|
+
passthroughHeaders: [],
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
psp: 'airwallex',
|
|
103
|
+
match: /(checkout(\.sandbox)?\.airwallex\.com|pci-api(\.sandbox)?\.airwallex\.com)\/api\/v1\/pa\/(payment_intents\/[^\/?#]+\/confirm|payment_consents\/[^\/?#]+\/verify|payment_methods\/create)(\?|$)/i,
|
|
104
|
+
hosts: ["^checkout\\.airwallex\\.com$", "^checkout\\.sandbox\\.airwallex\\.com$", "^pci-api\\.airwallex\\.com$", "^pci-api\\.sandbox\\.airwallex\\.com$"],
|
|
105
|
+
encoding: 'json',
|
|
106
|
+
passthroughHeaders: [/^client-secret$/i, /^authorization$/i, /^x-auth-token$/i, /^x-on-behalf-of$/i, /^x-api-version$/i],
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
psp: 'rapyd',
|
|
110
|
+
match: /(api|sandboxapi)\.rapyd\.net\/v1\/(?:hosted\/collect\/card\/[^\/?#]+\/payment_method|checkout\/checkout_[A-Za-z0-9]+)(?:[?#]|$)/i,
|
|
111
|
+
hosts: ["^api\\.rapyd\\.net$", "^sandboxapi\\.rapyd\\.net$"],
|
|
112
|
+
encoding: 'json',
|
|
113
|
+
passthroughHeaders: [],
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
psp: 'dlocal',
|
|
117
|
+
match: /ppmcc(-sandbox)?\.dlocal\.com\/cvault\/credit-card\/temporal/i,
|
|
118
|
+
hosts: ["^ppmcc\\.dlocal\\.com$", "^ppmcc-sandbox\\.dlocal\\.com$"],
|
|
119
|
+
encoding: 'json',
|
|
120
|
+
passthroughHeaders: [/^x-fields-api-key$/i, /^x-uow$/i, /^x-dlocal-infrav2$/i],
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
psp: 'ebanx',
|
|
124
|
+
match: /(?:(?:customer-frontier|customer|api-local-latam|api|sandbox-local-latam|sandbox)\.ebanx\.com|(?:api-diamond|api|sandbox)\.ebanxpay\.com)\/ws\/token/i,
|
|
125
|
+
hosts: ["^customer\\.ebanx\\.com$", "^customer-frontier\\.ebanx\\.com$", "^api\\.ebanx\\.com$", "^sandbox\\.ebanx\\.com$", "^api-local-latam\\.ebanx\\.com$", "^sandbox-local-latam\\.ebanx\\.com$", "^api-diamond\\.ebanxpay\\.com$", "^api\\.ebanxpay\\.com$", "^sandbox\\.ebanxpay\\.com$"],
|
|
126
|
+
encoding: 'json',
|
|
127
|
+
passthroughHeaders: [],
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
psp: 'mercado_pago',
|
|
131
|
+
match: /api\.mercadopago\.com\/v1\/card_tokens(?![\w\/-])/i,
|
|
132
|
+
hosts: ["^api\\.mercadopago\\.com$"],
|
|
133
|
+
encoding: 'json',
|
|
134
|
+
passthroughHeaders: [/^x-product-id$/i],
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
psp: 'payu',
|
|
138
|
+
match: /(secure\.payu\.com|(merch-prod|secure)\.snd\.payu\.com)\/api\/front\/tokens/i,
|
|
139
|
+
hosts: ["^secure\\.payu\\.com$", "^(merch-prod|secure)\\.snd\\.payu\\.com$"],
|
|
140
|
+
encoding: 'json',
|
|
141
|
+
passthroughHeaders: [],
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
psp: 'razorpay',
|
|
145
|
+
match: /api\.razorpay\.com\/v1\/(?:standard_checkout\/)?payments\/create\/(?:ajax|checkout|fees)/i,
|
|
146
|
+
hosts: ["^api\\.razorpay\\.com$"],
|
|
147
|
+
encoding: 'form',
|
|
148
|
+
passthroughHeaders: [/^x-razorpay-sessionid$/i, /^x-customer-access-token$/i],
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
psp: 'mollie',
|
|
152
|
+
match: /api\.cc\.mollie\.com\/v1\/card-tokens/i,
|
|
153
|
+
hosts: ["^api\\.cc\\.mollie\\.com$"],
|
|
154
|
+
encoding: 'json',
|
|
155
|
+
passthroughHeaders: [],
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
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$"],
|
|
161
|
+
encoding: 'json',
|
|
162
|
+
passthroughHeaders: [/^authorization$/i, /^x-paysafe-credentials$/i],
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
psp: 'recurly',
|
|
166
|
+
match: /api(\.eu)?\.recurly\.com\/js\/v1\/token(?![\w-])/i,
|
|
167
|
+
hosts: ["^api\\.recurly\\.com$", "^api\\.eu\\.recurly\\.com$"],
|
|
168
|
+
encoding: 'form',
|
|
169
|
+
passthroughHeaders: [/^recurly-credential-checkout-hostname$/i],
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
psp: 'moneris',
|
|
173
|
+
match: /(www3|esqa|gateway|gatewayqa|gatewayt|gatewaydev)\.moneris\.com\/HPPtoken\/request\.php/i,
|
|
174
|
+
hosts: ["^www3\\.moneris\\.com$", "^esqa\\.moneris\\.com$", "^gateway\\.moneris\\.com$", "^gatewayqa\\.moneris\\.com$", "^gatewayt\\.moneris\\.com$", "^gatewaydev\\.moneris\\.com$"],
|
|
175
|
+
encoding: 'form',
|
|
176
|
+
passthroughHeaders: [],
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
psp: 'bambora',
|
|
180
|
+
match: /(api\.na\.bambora\.com|api\.bam\.shift4api\.net)\/scripts\/tokenization\/tokens/i,
|
|
181
|
+
hosts: ["^api\\.na\\.bambora\\.com$", "^api\\.bam\\.shift4api\\.net$"],
|
|
182
|
+
encoding: 'json',
|
|
183
|
+
passthroughHeaders: [],
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
psp: 'global_payments',
|
|
187
|
+
match: /(?:api\.heartlandportico\.com\/SecureSubmit\.v1\/api\/token|cert\.api2\.heartlandportico\.com\/Hps\.Exchange\.PosGateway\.Hpf\.v1\/api\/token|apis(?:\.sandbox)?(?:\.eu)?\.globalpay\.com\/ucp\/(?:merchants\/[^\/]+\/)?payment-methods)/i,
|
|
188
|
+
hosts: ["^api\\.heartlandportico\\.com$", "^cert\\.api2\\.heartlandportico\\.com$", "^apis\\.globalpay\\.com$", "^apis\\.sandbox\\.globalpay\\.com$", "^apis\\.eu\\.globalpay\\.com$", "^apis\\.sandbox\\.eu\\.globalpay\\.com$"],
|
|
189
|
+
encoding: 'json',
|
|
190
|
+
passthroughHeaders: [/^authorization$/i, /^x-gp-version$/i],
|
|
41
191
|
},
|
|
42
192
|
];
|
|
43
193
|
export function findRecognizer(url, registry = BUILTIN_REGISTRY) {
|
|
44
|
-
|
|
194
|
+
// Match the way the SERVER does, on the PARSED url — never as a substring of
|
|
195
|
+
// the raw string. `match` is unanchored, so a raw-string test classifies any
|
|
196
|
+
// URL that merely CONTAINS a tokenizer endpoint as a card request:
|
|
197
|
+
// https://evil.com/?next=https://api.stripe.com/v1/tokens
|
|
198
|
+
// The server refuses that (its hosts[] are anchored against URL.hostname), so
|
|
199
|
+
// the request could not be intercepted anyway — but the SDK had already paused
|
|
200
|
+
// it, and a pause it cannot complete becomes an ABORT. That turns an
|
|
201
|
+
// integrator's unrelated POST into a failed request.
|
|
202
|
+
//
|
|
203
|
+
// hosts[] is synced from the API precisely so the client can apply the server's
|
|
204
|
+
// own allowlist. Use it when present, and fall back to matching `match`
|
|
205
|
+
// against "hostname + pathname" (not the query) for a registry served by an
|
|
206
|
+
// older API that predates the field.
|
|
207
|
+
let parsed;
|
|
208
|
+
try {
|
|
209
|
+
parsed = new URL(url);
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
if (parsed.protocol !== 'https:')
|
|
215
|
+
return null;
|
|
216
|
+
const hostPath = `${parsed.hostname.toLowerCase()}${parsed.pathname}`;
|
|
217
|
+
return (registry.find((r) => {
|
|
218
|
+
if (r.hosts && r.hosts.length > 0) {
|
|
219
|
+
const hostAllowed = r.hosts.some((h) => new RegExp(h, 'i').test(parsed.hostname));
|
|
220
|
+
if (!hostAllowed)
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
// FULLY ANCHORED, exactly as the server does it. Unanchored, any EXTENSION
|
|
224
|
+
// of a valid path is admitted on a legitimate processor host
|
|
225
|
+
// (api.stripe.com/v1/tokens/anything), which the service then refuses —
|
|
226
|
+
// and a pause the SDK cannot complete becomes an abort.
|
|
227
|
+
return new RegExp(`^(?:${r.match.source})$`, 'i').test(hostPath);
|
|
228
|
+
}) ?? null);
|
|
229
|
+
}
|
|
230
|
+
// ---------------------------------------------------------------------------
|
|
231
|
+
// Registry -> CDP url patterns
|
|
232
|
+
//
|
|
233
|
+
// `Fetch.enable` takes GLOB url patterns (`*` = any run of characters, `?` =
|
|
234
|
+
// exactly one, `\` escapes), never regexes, so the registry's anchored host
|
|
235
|
+
// regexes have to be widened into globs before a raw-CDP integrator can arm
|
|
236
|
+
// interception with them.
|
|
237
|
+
//
|
|
238
|
+
// These patterns are only a COARSE pre-filter. Every request they pause is
|
|
239
|
+
// re-checked with the real recognizer (VaultClient.isCardRequest) before
|
|
240
|
+
// anything is intercepted, so a pattern that is too BROAD costs one extra
|
|
241
|
+
// continueRequest round-trip, while one that is too NARROW silently drops the
|
|
242
|
+
// card request on the floor and the shopper pays with their own card. Every
|
|
243
|
+
// conversion below therefore WIDENS: anything not expressible as a glob
|
|
244
|
+
// (character classes, groups, quantifiers, lookarounds) becomes `*`.
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
/**
|
|
247
|
+
* Glob url patterns covering every host in `registry` — what to hand
|
|
248
|
+
* `Fetch.enable` so a card request is ever paused. Deliberately a superset of
|
|
249
|
+
* the recognizers; isCardRequest is the exact check.
|
|
250
|
+
*/
|
|
251
|
+
export function cardUrlPatterns(registry) {
|
|
252
|
+
const out = new Set();
|
|
253
|
+
for (const rec of registry ?? []) {
|
|
254
|
+
for (const pattern of recognizerUrlPatterns(rec))
|
|
255
|
+
out.add(pattern);
|
|
256
|
+
}
|
|
257
|
+
return [...out];
|
|
258
|
+
}
|
|
259
|
+
function recognizerUrlPatterns(rec) {
|
|
260
|
+
const hosts = Array.isArray(rec?.hosts) ? rec.hosts.filter((h) => typeof h === 'string') : [];
|
|
261
|
+
if (hosts.length > 0) {
|
|
262
|
+
// Host text ANYWHERE in the URL, not `*://host/*`. Two reasons: a URL with
|
|
263
|
+
// userinfo (`https://u:p@host/…`) still has `host` as its hostname so the
|
|
264
|
+
// registry matches it, and isCardRequest tests `match` unanchored against
|
|
265
|
+
// the raw URL, so a URL merely carrying the host in its query also
|
|
266
|
+
// "matches" client-side. Anchoring the scheme would arm narrower than
|
|
267
|
+
// either check.
|
|
268
|
+
//
|
|
269
|
+
// Lowercased because CDP matches these globs case-SENSITIVELY against the
|
|
270
|
+
// canonical url, and a canonical url's authority is always lowercase (the
|
|
271
|
+
// URL parser folds it before the request is ever issued) — the same
|
|
272
|
+
// normalization resolveRecognizerForUrl does with url.hostname. Do not
|
|
273
|
+
// "fix" this by wildcarding the letters: `*???.??????.???*` matches most of
|
|
274
|
+
// the web and would pause every request on the page.
|
|
275
|
+
return hosts.flatMap((h) => regexSourceToGlobs(h)).map((g) => wrap(g).toLowerCase());
|
|
276
|
+
}
|
|
277
|
+
// No hosts[] — a registry pinned before the field existed, or hand-built.
|
|
278
|
+
// Fall back to widening `match`, which is the predicate isCardRequest
|
|
279
|
+
// actually applies. Path case can matter to a glob (CDP matches
|
|
280
|
+
// case-sensitively) while a hostname is always lowercased by the URL parser,
|
|
281
|
+
// so arm both spellings rather than guess.
|
|
282
|
+
return regexSourceToGlobs(rec?.match?.source ?? '')
|
|
283
|
+
.map(wrap)
|
|
284
|
+
.flatMap((g) => (g === g.toLowerCase() ? [g] : [g, g.toLowerCase()]));
|
|
285
|
+
}
|
|
286
|
+
/** `a|b` at the top level is two independent hostnames, so it is two globs. */
|
|
287
|
+
function regexSourceToGlobs(source) {
|
|
288
|
+
return splitTopLevelAlternation(source).map(branchToGlob);
|
|
289
|
+
}
|
|
290
|
+
function splitTopLevelAlternation(source) {
|
|
291
|
+
const parts = [];
|
|
292
|
+
let depth = 0;
|
|
293
|
+
let inClass = false;
|
|
294
|
+
let start = 0;
|
|
295
|
+
for (let i = 0; i < source.length; i++) {
|
|
296
|
+
const c = source[i];
|
|
297
|
+
if (c === '\\') {
|
|
298
|
+
i++;
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
if (inClass) {
|
|
302
|
+
if (c === ']')
|
|
303
|
+
inClass = false;
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
if (c === '[') {
|
|
307
|
+
inClass = true;
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
if (c === '(') {
|
|
311
|
+
depth++;
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
if (c === ')') {
|
|
315
|
+
depth = Math.max(0, depth - 1);
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
if (c === '|' && depth === 0) {
|
|
319
|
+
parts.push(source.slice(start, i));
|
|
320
|
+
start = i + 1;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
parts.push(source.slice(start));
|
|
324
|
+
return parts;
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* One alternation-free regex source -> one glob. Literal characters survive;
|
|
328
|
+
* every construct that can match more than itself collapses to `*`. An empty
|
|
329
|
+
* result is `*` on purpose: an empty regex matches every hostname, so the glob
|
|
330
|
+
* that mirrors it has to match every URL.
|
|
331
|
+
*/
|
|
332
|
+
function branchToGlob(branch) {
|
|
333
|
+
const atoms = [];
|
|
334
|
+
let i = 0;
|
|
335
|
+
while (i < branch.length) {
|
|
336
|
+
const c = branch[i];
|
|
337
|
+
let atom;
|
|
338
|
+
if (c === '^' || c === '$') {
|
|
339
|
+
i += 1;
|
|
340
|
+
continue;
|
|
341
|
+
} // anchors constrain nothing in a glob
|
|
342
|
+
if (c === '\\') {
|
|
343
|
+
const next = branch[i + 1] ?? '';
|
|
344
|
+
i += 2;
|
|
345
|
+
// \d \w \s \b … are classes/assertions; only escaped punctuation (\., \-)
|
|
346
|
+
// is a literal character.
|
|
347
|
+
atom = /[A-Za-z0-9]/.test(next) ? '*' : globEscape(next);
|
|
348
|
+
}
|
|
349
|
+
else if (c === '[') {
|
|
350
|
+
i = skipCharClass(branch, i);
|
|
351
|
+
atom = '*';
|
|
352
|
+
}
|
|
353
|
+
else if (c === '(') {
|
|
354
|
+
i = skipGroup(branch, i); // groups, non-capturing groups, lookarounds
|
|
355
|
+
atom = '*';
|
|
356
|
+
}
|
|
357
|
+
else if (c === '.') {
|
|
358
|
+
i += 1;
|
|
359
|
+
atom = '*';
|
|
360
|
+
}
|
|
361
|
+
else if (c === ')' || c === ']') {
|
|
362
|
+
i += 1; // unbalanced closer: ignore rather than throw
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
else if (quantifierEnd(branch, i) !== null) {
|
|
366
|
+
// A quantifier with nothing in front of it. Widen whatever preceded it.
|
|
367
|
+
i = quantifierEnd(branch, i);
|
|
368
|
+
if (atoms.length > 0)
|
|
369
|
+
atoms[atoms.length - 1] = '*';
|
|
370
|
+
else
|
|
371
|
+
atoms.push('*');
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
else {
|
|
375
|
+
i += 1;
|
|
376
|
+
atom = globEscape(c);
|
|
377
|
+
}
|
|
378
|
+
// `x?` / `x*` / `x+` / `x{2,3}`: the atom is optional or repeatable, so the
|
|
379
|
+
// only glob that covers every string it accepts is `*`.
|
|
380
|
+
const after = quantifierEnd(branch, i);
|
|
381
|
+
if (after !== null) {
|
|
382
|
+
i = after;
|
|
383
|
+
atom = '*';
|
|
384
|
+
}
|
|
385
|
+
atoms.push(atom);
|
|
386
|
+
}
|
|
387
|
+
return collapse(atoms).join('');
|
|
388
|
+
}
|
|
389
|
+
/** Index just past a quantifier at `i`, or null when there isn't one. */
|
|
390
|
+
function quantifierEnd(source, i) {
|
|
391
|
+
const c = source[i];
|
|
392
|
+
let end;
|
|
393
|
+
if (c === '*' || c === '+' || c === '?') {
|
|
394
|
+
end = i + 1;
|
|
395
|
+
}
|
|
396
|
+
else if (c === '{') {
|
|
397
|
+
const close = source.indexOf('}', i);
|
|
398
|
+
if (close === -1 || !/^\{\d+(,\d*)?\}$/.test(source.slice(i, close + 1)))
|
|
399
|
+
return null;
|
|
400
|
+
end = close + 1;
|
|
401
|
+
}
|
|
402
|
+
else {
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
if (source[end] === '?')
|
|
406
|
+
end += 1; // lazy quantifier
|
|
407
|
+
return end;
|
|
408
|
+
}
|
|
409
|
+
function skipCharClass(source, i) {
|
|
410
|
+
let j = i + 1;
|
|
411
|
+
if (source[j] === '^')
|
|
412
|
+
j++;
|
|
413
|
+
if (source[j] === ']')
|
|
414
|
+
j++; // a leading `]` is a literal
|
|
415
|
+
for (; j < source.length; j++) {
|
|
416
|
+
if (source[j] === '\\') {
|
|
417
|
+
j++;
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
if (source[j] === ']')
|
|
421
|
+
return j + 1;
|
|
422
|
+
}
|
|
423
|
+
return source.length;
|
|
424
|
+
}
|
|
425
|
+
function skipGroup(source, i) {
|
|
426
|
+
let depth = 0;
|
|
427
|
+
let inClass = false;
|
|
428
|
+
for (let j = i; j < source.length; j++) {
|
|
429
|
+
const c = source[j];
|
|
430
|
+
if (c === '\\') {
|
|
431
|
+
j++;
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
if (inClass) {
|
|
435
|
+
if (c === ']')
|
|
436
|
+
inClass = false;
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
if (c === '[') {
|
|
440
|
+
inClass = true;
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
if (c === '(') {
|
|
444
|
+
depth++;
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
if (c === ')') {
|
|
448
|
+
depth--;
|
|
449
|
+
if (depth === 0)
|
|
450
|
+
return j + 1;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return source.length;
|
|
454
|
+
}
|
|
455
|
+
/** `*` and `?` are the glob's own wildcards; `\` is its escape. */
|
|
456
|
+
function globEscape(ch) {
|
|
457
|
+
return ch === '*' || ch === '?' || ch === '\\' ? `\\${ch}` : ch;
|
|
458
|
+
}
|
|
459
|
+
/** `**` matches exactly what `*` does; keep patterns readable. */
|
|
460
|
+
function collapse(atoms) {
|
|
461
|
+
const out = [];
|
|
462
|
+
for (const a of atoms) {
|
|
463
|
+
if (a === '*' && out[out.length - 1] === '*')
|
|
464
|
+
continue;
|
|
465
|
+
out.push(a);
|
|
466
|
+
}
|
|
467
|
+
return out;
|
|
468
|
+
}
|
|
469
|
+
/** `…host…` -> `*host*`, without doubling a `*` the conversion already emitted. */
|
|
470
|
+
function wrap(glob) {
|
|
471
|
+
let body = glob;
|
|
472
|
+
while (body.startsWith('*'))
|
|
473
|
+
body = body.slice(1);
|
|
474
|
+
while (body.length > 0 && body.endsWith('*') && !body.endsWith('\\*'))
|
|
475
|
+
body = body.slice(0, -1);
|
|
476
|
+
return body.length > 0 ? `*${body}*` : '*';
|
|
45
477
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cse half of a replay: put the vault's ciphertext into the paused body.
|
|
3
|
+
*
|
|
4
|
+
* On a client-side-encrypted processor (Adyen) the cardholder's device does
|
|
5
|
+
* not call the processor. It encrypts the card under the processor's public
|
|
6
|
+
* key and reports the ciphertext; the paused request then continues from the
|
|
7
|
+
* agent's own browser with those blobs in place of the dummy ones. Everything
|
|
8
|
+
* else in the body (session data, risk data, browser info, origin) stays the
|
|
9
|
+
* browser's own, so the processor's origin allowlist and risk checks see the
|
|
10
|
+
* request they expected.
|
|
11
|
+
*
|
|
12
|
+
* Deliberately narrow: only keys that ALREADY exist as strings at the named
|
|
13
|
+
* path are overwritten. A key is never added, a sibling is touched only when
|
|
14
|
+
* the API names it in `remove` (Adyen's `brand`, which adyen-web derived from
|
|
15
|
+
* the dummy digits the agent typed: left in place it describes the wrong card
|
|
16
|
+
* and Adyen refuses the mismatch), and a body that does not carry the fields
|
|
17
|
+
* is refused rather than guessed at.
|
|
18
|
+
*/
|
|
19
|
+
export interface Substitutions {
|
|
20
|
+
encoding: 'json';
|
|
21
|
+
/** Dotted path to the object that holds the fields, e.g. `paymentMethod`. */
|
|
22
|
+
at: string;
|
|
23
|
+
/** Field name -> ciphertext, exactly as the vault reported it. */
|
|
24
|
+
fields: Record<string, string>;
|
|
25
|
+
/**
|
|
26
|
+
* Sibling keys at `at` to delete when present (never an error when absent):
|
|
27
|
+
* values the page SDK derived from the agent's dummy card that would
|
|
28
|
+
* contradict the swapped ciphertext. Served by the API per processor.
|
|
29
|
+
*/
|
|
30
|
+
remove?: string[];
|
|
31
|
+
}
|
|
32
|
+
/** The paused body could not take the substitutions. Not terminal: the next request may differ. */
|
|
33
|
+
export declare class SubstitutionError extends Error {
|
|
34
|
+
constructor(message: string);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Return the paused JSON body with `sub.fields` written over the same-named
|
|
38
|
+
* string members of the object at `sub.at`. Throws SubstitutionError when the
|
|
39
|
+
* body is not JSON, the path does not lead to an object, or any field is not
|
|
40
|
+
* already a string there.
|
|
41
|
+
*/
|
|
42
|
+
export declare function substituteEncryptedFields(body: string, sub: Substitutions): string;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cse half of a replay: put the vault's ciphertext into the paused body.
|
|
3
|
+
*
|
|
4
|
+
* On a client-side-encrypted processor (Adyen) the cardholder's device does
|
|
5
|
+
* not call the processor. It encrypts the card under the processor's public
|
|
6
|
+
* key and reports the ciphertext; the paused request then continues from the
|
|
7
|
+
* agent's own browser with those blobs in place of the dummy ones. Everything
|
|
8
|
+
* else in the body (session data, risk data, browser info, origin) stays the
|
|
9
|
+
* browser's own, so the processor's origin allowlist and risk checks see the
|
|
10
|
+
* request they expected.
|
|
11
|
+
*
|
|
12
|
+
* Deliberately narrow: only keys that ALREADY exist as strings at the named
|
|
13
|
+
* path are overwritten. A key is never added, a sibling is touched only when
|
|
14
|
+
* the API names it in `remove` (Adyen's `brand`, which adyen-web derived from
|
|
15
|
+
* the dummy digits the agent typed: left in place it describes the wrong card
|
|
16
|
+
* and Adyen refuses the mismatch), and a body that does not carry the fields
|
|
17
|
+
* is refused rather than guessed at.
|
|
18
|
+
*/
|
|
19
|
+
/** The paused body could not take the substitutions. Not terminal: the next request may differ. */
|
|
20
|
+
export class SubstitutionError extends Error {
|
|
21
|
+
constructor(message) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = 'SubstitutionError';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
27
|
+
function isPlainObject(v) {
|
|
28
|
+
return !!v && typeof v === 'object' && !Array.isArray(v);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Return the paused JSON body with `sub.fields` written over the same-named
|
|
32
|
+
* string members of the object at `sub.at`. Throws SubstitutionError when the
|
|
33
|
+
* body is not JSON, the path does not lead to an object, or any field is not
|
|
34
|
+
* already a string there.
|
|
35
|
+
*/
|
|
36
|
+
export function substituteEncryptedFields(body, sub) {
|
|
37
|
+
if (!sub || sub.encoding !== 'json') {
|
|
38
|
+
throw new SubstitutionError(`unsupported substitution encoding: ${String(sub?.encoding)}`);
|
|
39
|
+
}
|
|
40
|
+
if (typeof sub.at !== 'string' || !sub.at)
|
|
41
|
+
throw new SubstitutionError('substitutions name no path');
|
|
42
|
+
if (!isPlainObject(sub.fields) || Object.keys(sub.fields).length === 0) {
|
|
43
|
+
throw new SubstitutionError('substitutions carry no fields');
|
|
44
|
+
}
|
|
45
|
+
let parsed;
|
|
46
|
+
try {
|
|
47
|
+
parsed = JSON.parse(body);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
throw new SubstitutionError('the paused body is not JSON');
|
|
51
|
+
}
|
|
52
|
+
if (!isPlainObject(parsed))
|
|
53
|
+
throw new SubstitutionError('the paused body is not a JSON object');
|
|
54
|
+
let node = parsed;
|
|
55
|
+
for (const step of sub.at.split('.')) {
|
|
56
|
+
if (!step || FORBIDDEN_KEYS.has(step))
|
|
57
|
+
throw new SubstitutionError(`refusing substitution path ${sub.at}`);
|
|
58
|
+
const next = node[step];
|
|
59
|
+
if (!isPlainObject(next))
|
|
60
|
+
throw new SubstitutionError(`the paused body has no object at ${sub.at}`);
|
|
61
|
+
node = next;
|
|
62
|
+
}
|
|
63
|
+
for (const [key, value] of Object.entries(sub.fields)) {
|
|
64
|
+
if (FORBIDDEN_KEYS.has(key))
|
|
65
|
+
throw new SubstitutionError(`refusing substitution field ${key}`);
|
|
66
|
+
if (typeof value !== 'string' || value.length === 0)
|
|
67
|
+
throw new SubstitutionError(`substitution for ${key} is not a string`);
|
|
68
|
+
if (typeof node[key] !== 'string')
|
|
69
|
+
throw new SubstitutionError(`the paused body has no string field ${sub.at}.${key}`);
|
|
70
|
+
}
|
|
71
|
+
const remove = sub.remove ?? [];
|
|
72
|
+
if (!Array.isArray(remove))
|
|
73
|
+
throw new SubstitutionError('substitutions.remove is not a list');
|
|
74
|
+
for (const key of remove) {
|
|
75
|
+
if (typeof key !== 'string' || !key || FORBIDDEN_KEYS.has(key))
|
|
76
|
+
throw new SubstitutionError(`refusing removal of ${String(key)}`);
|
|
77
|
+
if (key in sub.fields)
|
|
78
|
+
throw new SubstitutionError(`substitution field ${key} is also listed for removal`);
|
|
79
|
+
}
|
|
80
|
+
// Only past every check, so a refused body is never half-written.
|
|
81
|
+
for (const [key, value] of Object.entries(sub.fields))
|
|
82
|
+
node[key] = value;
|
|
83
|
+
for (const key of remove)
|
|
84
|
+
if (Object.prototype.hasOwnProperty.call(node, key))
|
|
85
|
+
delete node[key];
|
|
86
|
+
return JSON.stringify(parsed);
|
|
87
|
+
}
|