@agent-cards/checkout 0.18.0 → 0.19.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 +8 -0
- package/PREFLIGHT.md +4 -0
- package/README.md +91 -7
- package/dist/adyen-merchant-hosted.generated.d.ts +277 -0
- package/dist/adyen-merchant-hosted.generated.js +1902 -0
- package/dist/builtin-registry.generated.js +1 -1
- package/dist/cdp.d.ts +4 -1
- package/dist/cdp.js +416 -217
- package/dist/client.d.ts +236 -5
- package/dist/client.js +514 -11
- package/dist/cse-body.d.ts +25 -0
- package/dist/cse-body.js +41 -0
- package/dist/fiserv.d.ts +65 -0
- package/dist/fiserv.generated.d.ts +73 -0
- package/dist/fiserv.generated.js +830 -0
- package/dist/fiserv.js +104 -0
- package/dist/index.d.ts +7 -2
- package/dist/index.js +5 -1
- package/dist/lifecycle.d.ts +15 -1
- package/dist/lifecycle.js +28 -3
- package/dist/merchant-handoff.d.ts +54 -0
- package/dist/merchant-handoff.js +100 -0
- package/dist/merchant-hosted.d.ts +140 -0
- package/dist/merchant-hosted.js +170 -0
- package/dist/merchant-total-watch.d.ts +115 -0
- package/dist/merchant-total-watch.js +268 -0
- package/dist/merchant-total.d.ts +257 -0
- package/dist/merchant-total.js +383 -0
- package/dist/pre-claim.d.ts +123 -0
- package/dist/pre-claim.js +386 -0
- package/dist/preflight-catalog.json +132 -0
- package/dist/preflight-schemas.json +14 -2
- package/dist/preflight.generated.js +15 -1
- package/dist/preparation.d.ts +7 -0
- package/dist/preparation.js +33 -6
- package/dist/prepared-processor.d.ts +36 -3
- package/dist/prepared-processor.js +53 -3
- package/dist/registry.d.ts +45 -0
- package/dist/registry.js +14 -0
- package/dist/stripe-checkout.generated.js +96 -7
- package/dist/substitutions.generated.d.ts +2 -1
- package/dist/substitutions.generated.js +758 -6
- package/examples/preflight/kernel-native/inventory.json +1 -1
- package/package.json +3 -3
|
@@ -1,17 +1,699 @@
|
|
|
1
1
|
// @ts-nocheck
|
|
2
2
|
// Generated from @agent-cards/payment-core. Do not edit.
|
|
3
|
-
// artifact-sha256:
|
|
4
|
-
// src/
|
|
3
|
+
// artifact-sha256: 4079aa7704abbbbec052a96cb73463527ac5d027cd23780305c1e2ac954a5e88
|
|
4
|
+
// src/adyen-jwe.js
|
|
5
|
+
var ADYEN_JWE_HEADER = { alg: "RSA-OAEP-256", enc: "A256GCM", version: "1" };
|
|
6
|
+
var BASE64URL = /^[A-Za-z0-9_-]+$/;
|
|
7
|
+
function isAdyenJweCompact(s) {
|
|
8
|
+
if (typeof s !== "string" || s.length > 2048)
|
|
9
|
+
return false;
|
|
10
|
+
const parts = s.split(".");
|
|
11
|
+
if (parts.length !== 5)
|
|
12
|
+
return false;
|
|
13
|
+
const [header, encryptedKey, iv, ciphertext, tag] = parts;
|
|
14
|
+
if (!parts.every((p) => p.length > 0 && BASE64URL.test(p)))
|
|
15
|
+
return false;
|
|
16
|
+
if (encryptedKey.length !== 342 || iv.length !== 16 || tag.length !== 22)
|
|
17
|
+
return false;
|
|
18
|
+
if (ciphertext.length > 1024)
|
|
19
|
+
return false;
|
|
20
|
+
return isAdyenJweHeader(header);
|
|
21
|
+
}
|
|
22
|
+
function isAdyenJweHeader(segment) {
|
|
23
|
+
if (!BASE64URL.test(segment) || segment.length > 128)
|
|
24
|
+
return false;
|
|
25
|
+
let parsed;
|
|
26
|
+
try {
|
|
27
|
+
parsed = JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(segment.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0))));
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
33
|
+
return false;
|
|
34
|
+
const obj = parsed;
|
|
35
|
+
const keys = Object.keys(obj).sort();
|
|
36
|
+
if (keys.join(",") !== "alg,enc,version")
|
|
37
|
+
return false;
|
|
38
|
+
return obj.alg === ADYEN_JWE_HEADER.alg && obj.enc === ADYEN_JWE_HEADER.enc && obj.version === ADYEN_JWE_HEADER.version;
|
|
39
|
+
}
|
|
40
|
+
// src/json-path.js
|
|
41
|
+
var FORBIDDEN_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
|
|
42
|
+
function isPlainObject(v) {
|
|
43
|
+
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
44
|
+
}
|
|
45
|
+
var MAX_PATH_STEPS = 16;
|
|
46
|
+
function isJsonPath(path) {
|
|
47
|
+
return Array.isArray(path) && path.length <= MAX_PATH_STEPS && path.every((step) => typeof step === "string" ? step.length > 0 && !FORBIDDEN_KEYS.has(step) : Number.isSafeInteger(step) && step >= 0);
|
|
48
|
+
}
|
|
49
|
+
function jsonAt(root, path) {
|
|
50
|
+
let node = root;
|
|
51
|
+
for (const step of path) {
|
|
52
|
+
if (typeof step === "string") {
|
|
53
|
+
if (!isPlainObject(node) || FORBIDDEN_KEYS.has(step) || !Object.prototype.hasOwnProperty.call(node, step))
|
|
54
|
+
return { found: false };
|
|
55
|
+
}
|
|
56
|
+
else if (!Array.isArray(node) || !Number.isSafeInteger(step) || step < 0 || step >= node.length) {
|
|
57
|
+
return { found: false };
|
|
58
|
+
}
|
|
59
|
+
node = node[step];
|
|
60
|
+
}
|
|
61
|
+
return { found: true, value: node };
|
|
62
|
+
}
|
|
63
|
+
function roundTrips(parsed, body) {
|
|
64
|
+
try {
|
|
65
|
+
return JSON.stringify(parsed) === body;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function sameSteps(a, b) {
|
|
72
|
+
return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((step, i) => step === b[i]);
|
|
73
|
+
}
|
|
74
|
+
function pathsOverlap(a, b) {
|
|
75
|
+
const n = Math.min(a.length, b.length);
|
|
76
|
+
for (let i = 0; i < n; i += 1)
|
|
77
|
+
if (a[i] !== b[i])
|
|
78
|
+
return false;
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
function describePath(path) {
|
|
82
|
+
return path.length ? path.map((step) => typeof step === "number" ? `[${step}]` : `.${step}`).join("").replace(/^\./, "") : "(root)";
|
|
83
|
+
}
|
|
84
|
+
// src/substitution-primitives.js
|
|
5
85
|
var SubstitutionError = class extends Error {
|
|
6
86
|
constructor(message) {
|
|
7
87
|
super(message);
|
|
8
88
|
this.name = "SubstitutionError";
|
|
9
89
|
}
|
|
10
90
|
};
|
|
11
|
-
var
|
|
12
|
-
function
|
|
13
|
-
|
|
91
|
+
var SWAP_MEMBERS = /* @__PURE__ */ new Set(["path", "fields", "remove", "set"]);
|
|
92
|
+
function checkedSet(set, path, names, remove) {
|
|
93
|
+
if (!Array.isArray(set))
|
|
94
|
+
throw new SubstitutionError("substitutions.set is not a list");
|
|
95
|
+
const entries = [...set].map((raw) => {
|
|
96
|
+
const entry = isPlainObject(raw) ? Object.fromEntries(Object.entries(raw)) : null;
|
|
97
|
+
if (!entry || Object.keys(entry).sort().join(",") !== "path,value")
|
|
98
|
+
throw new SubstitutionError("a set entry is not a {path, value}");
|
|
99
|
+
const at = Array.isArray(entry.path) ? [...entry.path] : entry.path;
|
|
100
|
+
if (!isJsonPath(at) || at.length === 0 || typeof at[at.length - 1] !== "string")
|
|
101
|
+
throw new SubstitutionError("refusing set path");
|
|
102
|
+
const value = entry.value;
|
|
103
|
+
if (value !== null && !["string", "boolean"].includes(typeof value) && !(typeof value === "number" && Number.isFinite(value))) {
|
|
104
|
+
throw new SubstitutionError(`the set value at ${describePath(at)} is not a string, number, boolean or null`);
|
|
105
|
+
}
|
|
106
|
+
const key = at[at.length - 1];
|
|
107
|
+
if (sameSteps(at.slice(0, -1), path) && (names.includes(key) || remove.includes(key))) {
|
|
108
|
+
throw new SubstitutionError(`the set entry at ${describePath(at)} writes over an encrypted field or a removed key`);
|
|
109
|
+
}
|
|
110
|
+
return { path: at, value };
|
|
111
|
+
});
|
|
112
|
+
for (const [i, a] of entries.entries()) {
|
|
113
|
+
for (const b of entries.slice(i + 1)) {
|
|
114
|
+
if (pathsOverlap(a.path, b.path))
|
|
115
|
+
throw new SubstitutionError(`two set entries write at ${describePath(a.path)}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return entries;
|
|
14
119
|
}
|
|
120
|
+
function swapMerchantHostedFields(body, spec) {
|
|
121
|
+
if (!isPlainObject(spec))
|
|
122
|
+
throw new SubstitutionError("refusing a substitution that is not an object");
|
|
123
|
+
for (const member of Object.keys(spec)) {
|
|
124
|
+
if (!SWAP_MEMBERS.has(member))
|
|
125
|
+
throw new SubstitutionError(`unexpected substitution member ${member}`);
|
|
126
|
+
}
|
|
127
|
+
const path = Array.isArray(spec.path) ? [...spec.path] : spec.path;
|
|
128
|
+
const fields = isPlainObject(spec.fields) ? Object.fromEntries(Object.entries(spec.fields)) : null;
|
|
129
|
+
const remove = Array.isArray(spec.remove) ? [...spec.remove] : spec.remove ?? [];
|
|
130
|
+
if (!isJsonPath(path))
|
|
131
|
+
throw new SubstitutionError("refusing substitution path");
|
|
132
|
+
if (!fields || Object.keys(fields).length === 0)
|
|
133
|
+
throw new SubstitutionError("substitutions carry no fields");
|
|
134
|
+
const names = Object.keys(fields);
|
|
135
|
+
const values = names.map((name) => fields[name]);
|
|
136
|
+
for (const name of names) {
|
|
137
|
+
if (FORBIDDEN_KEYS.has(name))
|
|
138
|
+
throw new SubstitutionError(`refusing substitution field ${name}`);
|
|
139
|
+
if (!isAdyenJweCompact(fields[name]))
|
|
140
|
+
throw new SubstitutionError(`substitution for ${name} is not an Adyen JWE`);
|
|
141
|
+
}
|
|
142
|
+
if (!Array.isArray(remove))
|
|
143
|
+
throw new SubstitutionError("substitutions.remove is not a list");
|
|
144
|
+
for (const key of remove) {
|
|
145
|
+
if (typeof key !== "string" || !key || FORBIDDEN_KEYS.has(key))
|
|
146
|
+
throw new SubstitutionError(`refusing removal of ${String(key)}`);
|
|
147
|
+
if (names.includes(key))
|
|
148
|
+
throw new SubstitutionError(`substitution field ${key} is also listed for removal`);
|
|
149
|
+
}
|
|
150
|
+
if (new Set(remove).size !== remove.length)
|
|
151
|
+
throw new SubstitutionError("substitutions.remove names a key twice");
|
|
152
|
+
const set = checkedSet(spec.set ?? [], path, names, remove);
|
|
153
|
+
if (typeof body !== "string")
|
|
154
|
+
throw new SubstitutionError("the paused body is not JSON");
|
|
155
|
+
let parsed;
|
|
156
|
+
try {
|
|
157
|
+
parsed = JSON.parse(body);
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
throw new SubstitutionError("the paused body is not JSON");
|
|
161
|
+
}
|
|
162
|
+
if (!isPlainObject(parsed))
|
|
163
|
+
throw new SubstitutionError("the paused body is not a JSON object");
|
|
164
|
+
const holder = jsonAt(parsed, path);
|
|
165
|
+
if (!holder.found || !isPlainObject(holder.value))
|
|
166
|
+
throw new SubstitutionError(`the paused body has no object at ${describePath(path)}`);
|
|
167
|
+
const swaps = /* @__PURE__ */ new Map();
|
|
168
|
+
for (const name of names) {
|
|
169
|
+
const dummy = Object.prototype.hasOwnProperty.call(holder.value, name) ? holder.value[name] : void 0;
|
|
170
|
+
if (typeof dummy !== "string")
|
|
171
|
+
throw new SubstitutionError(`the paused body has no string field ${name} at ${describePath(path)}`);
|
|
172
|
+
if (!isAdyenJweCompact(dummy))
|
|
173
|
+
throw new SubstitutionError(`the paused field ${name} is not an Adyen JWE`);
|
|
174
|
+
if (swaps.has(dummy))
|
|
175
|
+
throw new SubstitutionError("two paused fields carry the same ciphertext");
|
|
176
|
+
swaps.set(dummy, fields[name]);
|
|
177
|
+
}
|
|
178
|
+
const dummies = [...swaps.keys()];
|
|
179
|
+
if (new Set(values).size !== values.length || values.some((value) => swaps.has(value))) {
|
|
180
|
+
throw new SubstitutionError("two ciphertexts in this substitution are the same");
|
|
181
|
+
}
|
|
182
|
+
const texts = [...dummies, ...values];
|
|
183
|
+
for (const a of texts)
|
|
184
|
+
for (const b of texts)
|
|
185
|
+
if (a !== b && b.includes(a))
|
|
186
|
+
throw new SubstitutionError("one ciphertext is part of another");
|
|
187
|
+
if ((remove.length > 0 || set.length > 0) && !roundTrips(parsed, body)) {
|
|
188
|
+
throw new SubstitutionError("the paused body does not round-trip through JSON, so it cannot be edited");
|
|
189
|
+
}
|
|
190
|
+
for (const entry of set) {
|
|
191
|
+
const parent = jsonAt(parsed, entry.path.slice(0, -1));
|
|
192
|
+
if (!parent.found || !isPlainObject(parent.value)) {
|
|
193
|
+
throw new SubstitutionError(`the paused body has no object at ${describePath(entry.path.slice(0, -1))} to write a value in`);
|
|
194
|
+
}
|
|
195
|
+
const key = entry.path[entry.path.length - 1];
|
|
196
|
+
const current = Object.prototype.hasOwnProperty.call(parent.value, key) ? parent.value[key] : void 0;
|
|
197
|
+
if (current !== null && typeof current === "object") {
|
|
198
|
+
throw new SubstitutionError(`refusing to write over the object at ${describePath(entry.path)}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const seen = /* @__PURE__ */ new Set();
|
|
202
|
+
const pattern = new RegExp(dummies.map((text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|"), "g");
|
|
203
|
+
const swapped = body.replace(pattern, (text) => {
|
|
204
|
+
seen.add(text);
|
|
205
|
+
return swaps.get(text);
|
|
206
|
+
});
|
|
207
|
+
if (seen.size !== dummies.length)
|
|
208
|
+
throw new SubstitutionError("a paused field is not written verbatim in the body");
|
|
209
|
+
let out;
|
|
210
|
+
try {
|
|
211
|
+
out = JSON.parse(swapped);
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
throw new SubstitutionError("the substituted body is not JSON");
|
|
215
|
+
}
|
|
216
|
+
const written = jsonAt(out, path);
|
|
217
|
+
if (!written.found || !isPlainObject(written.value) || names.some((name) => written.value[name] !== fields[name]) || dummies.some((dummy) => swapped.includes(dummy))) {
|
|
218
|
+
throw new SubstitutionError(`the substitution did not land at ${describePath(path)}`);
|
|
219
|
+
}
|
|
220
|
+
if (remove.length === 0 && set.length === 0)
|
|
221
|
+
return swapped;
|
|
222
|
+
for (const key of remove)
|
|
223
|
+
if (Object.prototype.hasOwnProperty.call(written.value, key))
|
|
224
|
+
delete written.value[key];
|
|
225
|
+
for (const entry of set) {
|
|
226
|
+
const parent = jsonAt(out, entry.path.slice(0, -1));
|
|
227
|
+
if (!parent.found || !isPlainObject(parent.value))
|
|
228
|
+
throw new SubstitutionError(`the set entry at ${describePath(entry.path)} did not land`);
|
|
229
|
+
parent.value[entry.path[entry.path.length - 1]] = entry.value;
|
|
230
|
+
}
|
|
231
|
+
return JSON.stringify(out);
|
|
232
|
+
}
|
|
233
|
+
// src/deep-freeze.js
|
|
234
|
+
function deepFreeze(value) {
|
|
235
|
+
if (value && typeof value === "object" && !Object.isFrozen(value)) {
|
|
236
|
+
for (const child of Object.values(value))
|
|
237
|
+
deepFreeze(child);
|
|
238
|
+
Object.freeze(value);
|
|
239
|
+
}
|
|
240
|
+
return value;
|
|
241
|
+
}
|
|
242
|
+
// src/merchant-amount.js
|
|
243
|
+
function merchantAmountCardPaths(profile) {
|
|
244
|
+
const source = isPlainObject(profile) ? profile.amountSource : void 0;
|
|
245
|
+
if (!isPlainObject(source))
|
|
246
|
+
return [];
|
|
247
|
+
const rules = [source, isPlainObject(source.afterPayment) ? source.afterPayment : {}].flatMap((stage) => [...Array.isArray(stage.bind) ? stage.bind : [], ...Array.isArray(stage.match) ? stage.match : []]);
|
|
248
|
+
return rules.flatMap((rule) => isPlainObject(rule) ? [rule.card, rule.whenCard] : []).filter(Array.isArray);
|
|
249
|
+
}
|
|
250
|
+
// src/merchant-profiles.js
|
|
251
|
+
var MERCHANT_HOSTED_PROFILES = /* @__PURE__ */ deepFreeze([
|
|
252
|
+
{
|
|
253
|
+
// Read Sep 23, 2026. assets.dunelm.com checkout-page bundle: submitPayment
|
|
254
|
+
// sends {operationName: 'AdyenPayment', variables: {input: {paymentMethod,
|
|
255
|
+
// browserInfo, idempotencyKey, amount, order, provisionalOrderId,
|
|
256
|
+
// shopperInteraction, storePaymentMethod}}, query} through Apollo to the
|
|
257
|
+
// page config's was.url, https://was.dunelm.com/graphql. The same config
|
|
258
|
+
// names the client key and environment 'live' (adyen-web 5.31.3). Every other
|
|
259
|
+
// GraphQL operation shares this endpoint, gift card redemption included, with
|
|
260
|
+
// its own encrypted fields, so only operationName AdyenPayment is this
|
|
261
|
+
// profile's card request. Public key read from checkoutshopper-live.adyen.com
|
|
262
|
+
// on 2026-09-25 (UTC).
|
|
263
|
+
id: "dunelm_was_graphql",
|
|
264
|
+
merchant: "Dunelm",
|
|
265
|
+
status: "observe",
|
|
266
|
+
origins: [{ checkout: "https://www.dunelm.com", endpoint: "https://was.dunelm.com" }],
|
|
267
|
+
method: "POST",
|
|
268
|
+
path: "/graphql",
|
|
269
|
+
query: {},
|
|
270
|
+
requires: [{ path: ["operationName"], equals: "AdyenPayment" }],
|
|
271
|
+
holder: ["variables", "input", "paymentMethod"],
|
|
272
|
+
fields: {
|
|
273
|
+
encryptedCardNumber: "encryptedCardNumber",
|
|
274
|
+
encryptedExpiryMonth: "encryptedExpiryMonth",
|
|
275
|
+
encryptedExpiryYear: "encryptedExpiryYear",
|
|
276
|
+
encryptedSecurityCode: "encryptedSecurityCode"
|
|
277
|
+
},
|
|
278
|
+
// adyen-web stamps brand from the digits typed into its Card component, the
|
|
279
|
+
// agent's dummy; Adyen refuses a brand that disagrees with the decrypted card
|
|
280
|
+
// (422/109) and reads the brand off the card when none is sent.
|
|
281
|
+
remove: ["brand"],
|
|
282
|
+
refuse: [
|
|
283
|
+
{ path: ["variables", "input", "storePaymentMethod"], when: "true" },
|
|
284
|
+
{ path: ["variables", "input", "paymentMethod", "storedPaymentMethodId"], when: "present" },
|
|
285
|
+
{ path: ["variables", "input", "paymentMethod", "recurringDetailReference"], when: "present" }
|
|
286
|
+
],
|
|
287
|
+
amount: ["variables", "input", "amount"],
|
|
288
|
+
// Read Sep 25, 2026 (research amount_source/dunelm). The body names an amount only
|
|
289
|
+
// when the gift_card flag is on and AdyenOrder answered, and then the card's share,
|
|
290
|
+
// so the total comes from CheckoutView, the provisional order the page shows and
|
|
291
|
+
// pays: a GraphQL query on the card request's own URL, told apart by operationName.
|
|
292
|
+
// prices.total is a GraphQL Float in pounds; the response names no currency and
|
|
293
|
+
// Dunelm sells only in GBP. After payment the card request's own answer carries
|
|
294
|
+
// transaction.amount (pounds, unit inferred until a HAR).
|
|
295
|
+
amountSource: {
|
|
296
|
+
id: "dunelm_checkout_view",
|
|
297
|
+
origin: "endpoint",
|
|
298
|
+
format: "json",
|
|
299
|
+
responses: [{
|
|
300
|
+
method: "POST",
|
|
301
|
+
path: "/graphql",
|
|
302
|
+
query: {},
|
|
303
|
+
request: [{ path: ["operationName"], equals: "CheckoutView" }],
|
|
304
|
+
at: ["data", "viewer", "checkoutProvisionalOrder"]
|
|
305
|
+
}],
|
|
306
|
+
total: { path: ["prices", "total"], format: "number" },
|
|
307
|
+
unit: "major",
|
|
308
|
+
currency: "GBP",
|
|
309
|
+
bind: [{ source: ["provisionalOrderId"], card: ["variables", "input", "provisionalOrderId"] }],
|
|
310
|
+
// false means nothing is left for the card (a gift card paid it all).
|
|
311
|
+
requires: [{ path: ["flags", "paymentRequired"], equals: true }],
|
|
312
|
+
// A GraphQL error result is not a total, even beside partial data.
|
|
313
|
+
refuse: [{ path: ["errors"], when: "present", root: true }],
|
|
314
|
+
maxAgeMs: 9e5,
|
|
315
|
+
afterPayment: {
|
|
316
|
+
origin: "endpoint",
|
|
317
|
+
format: "json",
|
|
318
|
+
responses: [{
|
|
319
|
+
method: "POST",
|
|
320
|
+
path: "/graphql",
|
|
321
|
+
query: {},
|
|
322
|
+
request: [{ path: ["operationName"], equals: "AdyenPayment" }],
|
|
323
|
+
at: ["data", "adyenPayment"]
|
|
324
|
+
}],
|
|
325
|
+
total: { path: ["transaction", "amount"], format: "number" },
|
|
326
|
+
unit: "major",
|
|
327
|
+
currency: "GBP",
|
|
328
|
+
bind: [{ request: ["variables", "input", "provisionalOrderId"], card: ["variables", "input", "provisionalOrderId"] }]
|
|
329
|
+
}
|
|
330
|
+
},
|
|
331
|
+
adyen: {
|
|
332
|
+
environment: "live",
|
|
333
|
+
clientKey: "live_3APNTGTFX5FENHWGWUJEKGYSKY7YKURV",
|
|
334
|
+
publicKeySha256: "0fc63087b90dc0958fc2c024aafca1cd8b6feb34b6f53bb19b1b5649f3e6c316"
|
|
335
|
+
}
|
|
336
|
+
},
|
|
337
|
+
{
|
|
338
|
+
// Read Sep 23, 2026. www.cinemark.com/scripts/app/adyenIntegration.js posts
|
|
339
|
+
// state.data plus root amount, store and additionalData, through
|
|
340
|
+
// JSON.stringify, to /api/adyen/initiatePayment (stored cards go to its
|
|
341
|
+
// sibling initiateStoredPayment), and maps the environment 'live' to
|
|
342
|
+
// 'live-us'. A card-free observe run on the guest ticket checkout read the
|
|
343
|
+
// client key from the three securedFields iframes on
|
|
344
|
+
// checkoutshopper-live-us.adyen.com (adyen-web 6.x). The request body itself
|
|
345
|
+
// was not captured: the guest email gate stopped the run before Pay. Public
|
|
346
|
+
// key read from checkoutshopper-live-us.adyen.com on 2026-09-25 (UTC).
|
|
347
|
+
id: "cinemark_initiate",
|
|
348
|
+
merchant: "Cinemark",
|
|
349
|
+
status: "observe",
|
|
350
|
+
origins: [{ checkout: "https://www.cinemark.com", endpoint: "https://www.cinemark.com" }],
|
|
351
|
+
method: "POST",
|
|
352
|
+
path: "/api/adyen/initiatePayment",
|
|
353
|
+
query: {},
|
|
354
|
+
requires: [],
|
|
355
|
+
holder: ["paymentMethod"],
|
|
356
|
+
fields: {
|
|
357
|
+
encryptedCardNumber: "encryptedCardNumber",
|
|
358
|
+
encryptedExpiryMonth: "encryptedExpiryMonth",
|
|
359
|
+
encryptedExpiryYear: "encryptedExpiryYear",
|
|
360
|
+
encryptedSecurityCode: "encryptedSecurityCode"
|
|
361
|
+
},
|
|
362
|
+
// adyen-web 6.x stamps brand after its bin lookup answers, so a body carries
|
|
363
|
+
// the dummy's brand on some Pay presses and not others: removed either way.
|
|
364
|
+
remove: ["brand"],
|
|
365
|
+
refuse: [
|
|
366
|
+
{ path: ["storePaymentMethod"], when: "true" },
|
|
367
|
+
{ path: ["paymentMethod", "storedPaymentMethodId"], when: "present" },
|
|
368
|
+
{ path: ["paymentMethod", "recurringDetailReference"], when: "present" },
|
|
369
|
+
// adyenIntegration.js adds additionalData.existingPaymentId only when its
|
|
370
|
+
// caller passes one to submitCheckout; the guest flow passes none. What it
|
|
371
|
+
// points at (a stored card, or an earlier payment this one continues) is
|
|
372
|
+
// unread, so a body that carries one is refused until a card-free capture
|
|
373
|
+
// shows it.
|
|
374
|
+
{ path: ["additionalData", "existingPaymentId"], when: "present" },
|
|
375
|
+
// Present only when Cinemark gift cards pay part of the order: the card then pays
|
|
376
|
+
// only its share, which is not the order's total.
|
|
377
|
+
{ path: ["additionalData", "giftCardAmount"], when: "present" }
|
|
378
|
+
],
|
|
379
|
+
amount: ["amount"],
|
|
380
|
+
// Read Sep 25, 2026 (research amount_source/cinemark). The body's amount is computed
|
|
381
|
+
// by the page's script in the agent's browser (total, plus any tip, minus gift
|
|
382
|
+
// cards); the server renders the total into the checkout HTML, as the hidden input
|
|
383
|
+
// CartSummary_TotalWithServiceFee (dollars), or the inline var cartTotal when that
|
|
384
|
+
// input is not there. The page reloads /Checkout after every cart change. The cart
|
|
385
|
+
// is the session's, so the response is tied to the card request by its page. A
|
|
386
|
+
// tip makes the body differ from the total and is refused, as the rule for a body
|
|
387
|
+
// amount beside a source says. After payment, initiatePayment's answer names the
|
|
388
|
+
// amount (minor units) when Cinemark relays Adyen's response: a HAR confirms it.
|
|
389
|
+
amountSource: {
|
|
390
|
+
id: "cinemark_checkout_page_total",
|
|
391
|
+
origin: "endpoint",
|
|
392
|
+
format: "html",
|
|
393
|
+
responses: [
|
|
394
|
+
{ method: "GET", path: "/checkout", query: {} },
|
|
395
|
+
{ method: "GET", path: "/Checkout", query: {} }
|
|
396
|
+
],
|
|
397
|
+
total: { input: "CartSummary_TotalWithServiceFee", scriptVar: "cartTotal", format: "decimal" },
|
|
398
|
+
unit: "major",
|
|
399
|
+
currency: "USD",
|
|
400
|
+
bind: [{ samePage: true }],
|
|
401
|
+
maxAgeMs: 9e5,
|
|
402
|
+
afterPayment: {
|
|
403
|
+
origin: "endpoint",
|
|
404
|
+
format: "json",
|
|
405
|
+
responses: [
|
|
406
|
+
{ method: "POST", path: "/api/adyen/initiatePayment", query: {} },
|
|
407
|
+
{ method: "POST", path: "/api/adyen/submitAdditionalDetails", query: {} }
|
|
408
|
+
],
|
|
409
|
+
total: { path: ["amount", "value"], format: "number" },
|
|
410
|
+
unit: "minor",
|
|
411
|
+
currency: "USD",
|
|
412
|
+
currencyPath: ["amount", "currency"],
|
|
413
|
+
bind: [{ samePage: true }]
|
|
414
|
+
}
|
|
415
|
+
},
|
|
416
|
+
adyen: {
|
|
417
|
+
environment: "live-us",
|
|
418
|
+
clientKey: "live_AFKCBP443ZGQNIY4OCYW3YCKDY6VIKNN",
|
|
419
|
+
publicKeySha256: "9dd5ef549c7454d60ca970ff76c8f4cac36d79f2961e59eca9116791e86cbcc5"
|
|
420
|
+
}
|
|
421
|
+
},
|
|
422
|
+
{
|
|
423
|
+
// Read Sep 23, 2026. payments.dickssportinggoods.com/library/dppjs/index-v2.js
|
|
424
|
+
// (adyen-web 6.39.0): Dd() builds tenders.encryptedCardTenders[0] from the Card
|
|
425
|
+
// component's four JWEs as cardNumber, expirationMonth, expirationYear and cvv,
|
|
426
|
+
// beside cardType (adyen-web's first detected brand), saveForLater (the save-card
|
|
427
|
+
// box) and vinBalue (the six digits onBinValue reports). The page posts it with
|
|
428
|
+
// transactionReference, customer.device and any giftCardTenders to
|
|
429
|
+
// <payments host>/prod/api/paygate/v2/authorization, the host picked from the
|
|
430
|
+
// page's own (Dick's, Golf Galaxy, Public Lands, Going Going Gone). The same slot
|
|
431
|
+
// carries Afterpay, CashApp and Affirm tenders with literal strings where the JWEs
|
|
432
|
+
// go, and a saved card goes to tenders.savedCardTender instead. The body names no
|
|
433
|
+
// amount: the paygate charges the cart it holds for transactionReference.
|
|
434
|
+
// mountCCForm names the client key and environment 'live'. Public key read from
|
|
435
|
+
// checkoutshopper-live.adyen.com on 2026-09-25 (UTC).
|
|
436
|
+
id: "dsg_paygate_v2",
|
|
437
|
+
merchant: "Dick's Sporting Goods",
|
|
438
|
+
status: "observe",
|
|
439
|
+
origins: [
|
|
440
|
+
{ checkout: "https://www.dickssportinggoods.com", endpoint: "https://payments.dickssportinggoods.com" },
|
|
441
|
+
{ checkout: "https://www.golfgalaxy.com", endpoint: "https://payments.golfgalaxy.com" },
|
|
442
|
+
{ checkout: "https://www.publiclands.com", endpoint: "https://payments.publiclands.com" },
|
|
443
|
+
{ checkout: "https://www.goinggoinggone.com", endpoint: "https://payments.goinggoinggone.com" }
|
|
444
|
+
],
|
|
445
|
+
method: "POST",
|
|
446
|
+
path: "/prod/api/paygate/v2/authorization",
|
|
447
|
+
query: {},
|
|
448
|
+
// The page sends true for every checkout its translator read, the only checkouts
|
|
449
|
+
// the amount source below maps; a legacy checkout is not this card request.
|
|
450
|
+
requires: [{ path: ["isModernOrder"], equals: true }],
|
|
451
|
+
holder: ["tenders", "encryptedCardTenders", 0],
|
|
452
|
+
fields: {
|
|
453
|
+
encryptedCardNumber: "cardNumber",
|
|
454
|
+
encryptedExpiryMonth: "expirationMonth",
|
|
455
|
+
encryptedExpiryYear: "expirationYear",
|
|
456
|
+
encryptedSecurityCode: "cvv"
|
|
457
|
+
},
|
|
458
|
+
// cardType and vinBalue describe the dummy the agent typed: the facts below
|
|
459
|
+
// write the approved card's instead, so nothing is removed.
|
|
460
|
+
remove: [],
|
|
461
|
+
refuse: [
|
|
462
|
+
{ path: ["tenders", "encryptedCardTenders", 0, "saveForLater"], when: "true" },
|
|
463
|
+
// A saved card, or a second card tender the Vault never encrypted.
|
|
464
|
+
{ path: ["tenders", "savedCardTender"], when: "present" },
|
|
465
|
+
{ path: ["tenders", "encryptedCardTenders", 1], when: "present" },
|
|
466
|
+
// A gift card pays part of the order, so the card pays less than the total.
|
|
467
|
+
{ path: ["tenders", "giftCardTenders"], when: "present" }
|
|
468
|
+
],
|
|
469
|
+
amount: null,
|
|
470
|
+
// Read Sep 25, 2026 (research amount_source/dickssportinggoods). Every checkout call
|
|
471
|
+
// on the checkout page answers the whole checkout, camelCase on the wire: the page
|
|
472
|
+
// load's PATCH (or the create), then every address, shipping, promotion, gift card,
|
|
473
|
+
// tip and ScoreCard change. pricing.totalRemaining is what the card pays, a string
|
|
474
|
+
// with exactly two decimals ("123.45"; the page itself drops the dot for minor
|
|
475
|
+
// units). transactionReference is the checkout key, in the body and the URL. After
|
|
476
|
+
// payment the order submit and the confirmation read return the placed checkout.
|
|
477
|
+
amountSource: {
|
|
478
|
+
id: "dsg_checkout_pricing",
|
|
479
|
+
origin: "checkout",
|
|
480
|
+
format: "json",
|
|
481
|
+
responses: [
|
|
482
|
+
{ method: "PATCH", path: "/api/v1/checkouts/:checkoutKey", query: {} },
|
|
483
|
+
{ method: "POST", path: "/api/v1/checkouts", query: {} },
|
|
484
|
+
{ method: "POST", path: "/api/v1/checkouts/:checkoutKey/addresses", queries: [{}, { type: "billing" }, { type: "shipping" }] },
|
|
485
|
+
{ method: "PUT", path: "/api/v1/checkouts/:checkoutKey/shipping/selected/groups/:groupNumber/mode", query: {} },
|
|
486
|
+
{ method: "PUT", path: "/api/v2/checkouts/:checkoutKey/promotion/cart", query: {} },
|
|
487
|
+
{ method: "DELETE", path: "/api/v2/checkouts/:checkoutKey/promotion/cart", query: {} },
|
|
488
|
+
{ method: "POST", path: "/api/v1/checkouts/:checkoutKey/gift-cards", query: {} },
|
|
489
|
+
{ method: "DELETE", path: "/api/v1/checkouts/:checkoutKey/gift-cards/:giftCardIndex", query: {} },
|
|
490
|
+
{ method: "PUT", path: "/api/v1/checkouts/:checkoutKey/tip", query: {} },
|
|
491
|
+
{ method: "PUT", path: "/api/v1/checkouts/:checkoutKey/scorecard", query: {} },
|
|
492
|
+
{ method: "DELETE", path: "/api/v1/checkouts/:checkoutKey/scorecard", query: {} },
|
|
493
|
+
{ method: "DELETE", path: "/api/v1/checkouts/:checkoutKey/v2/gifts", query: {} }
|
|
494
|
+
],
|
|
495
|
+
total: { path: ["pricing", "totalRemaining"], format: "decimal2" },
|
|
496
|
+
unit: "major",
|
|
497
|
+
currency: "USD",
|
|
498
|
+
currencyPath: ["pricing", "currency"],
|
|
499
|
+
bind: [
|
|
500
|
+
{ source: ["checkoutKey"], card: ["transactionReference"] },
|
|
501
|
+
{ placeholder: "checkoutKey", card: ["transactionReference"] }
|
|
502
|
+
],
|
|
503
|
+
// The order total and the card's share are the same text. Anything that lowers the
|
|
504
|
+
// share below the total (a gift card, ScoreCard rewards) or lands in only one of them
|
|
505
|
+
// (a tip, a donation) is refused until a HAR shows which one the charge follows, so
|
|
506
|
+
// the approved amount and the placed checkout's total read after payment agree.
|
|
507
|
+
requires: [{ path: ["pricing", "total"], equalsPath: ["pricing", "totalRemaining"] }],
|
|
508
|
+
refuse: [
|
|
509
|
+
// A placed checkout is not the one being paid.
|
|
510
|
+
{ path: ["orderPlacedTime"], when: "true" },
|
|
511
|
+
// Gift cards and a checkout with no address yet (tax not estimated) wait for a HAR.
|
|
512
|
+
{ path: ["payment", "giftCards"], when: "nonEmpty" },
|
|
513
|
+
{ path: ["addresses"], when: "empty" }
|
|
514
|
+
],
|
|
515
|
+
maxAgeMs: 9e5,
|
|
516
|
+
afterPayment: {
|
|
517
|
+
origin: "checkout",
|
|
518
|
+
format: "json",
|
|
519
|
+
responses: [
|
|
520
|
+
{ method: "POST", path: "/api/v1/checkouts/:checkoutKey", query: {} },
|
|
521
|
+
{ method: "GET", path: "/api/v1/checkouts/submitted/:checkoutKey", query: {} }
|
|
522
|
+
],
|
|
523
|
+
// The order total, which the requires rule above held equal to the card's share
|
|
524
|
+
// before payment. totalRemaining is not read here: after placement it can drop to
|
|
525
|
+
// what is still unpaid, which would hide a higher charge.
|
|
526
|
+
total: { path: ["pricing", "total"], format: "decimal2" },
|
|
527
|
+
unit: "major",
|
|
528
|
+
currency: "USD",
|
|
529
|
+
currencyPath: ["pricing", "currency"],
|
|
530
|
+
bind: [
|
|
531
|
+
{ source: ["checkoutKey"], card: ["transactionReference"] },
|
|
532
|
+
{ placeholder: "checkoutKey", card: ["transactionReference"] }
|
|
533
|
+
],
|
|
534
|
+
refuse: [{ path: ["orderPlacedTime"], when: "unset" }]
|
|
535
|
+
}
|
|
536
|
+
},
|
|
537
|
+
facts: [
|
|
538
|
+
// adyen-web's brand codes, the ones the page's supported list names; Dick's own
|
|
539
|
+
// private-label (plcc, comc) cards have no Agentcard brand and are refused.
|
|
540
|
+
{
|
|
541
|
+
path: ["tenders", "encryptedCardTenders", 0, "cardType"],
|
|
542
|
+
fact: "brand",
|
|
543
|
+
map: { visa: "visa", mastercard: "mc", amex: "amex", discover: "discover", diners: "diners", jcb: "jcb" }
|
|
544
|
+
},
|
|
545
|
+
{ path: ["tenders", "encryptedCardTenders", 0, "vinBalue"], fact: "bin6" }
|
|
546
|
+
],
|
|
547
|
+
adyen: {
|
|
548
|
+
environment: "live",
|
|
549
|
+
clientKey: "live_YJLFKT47JJG2RI7QIYX6HTKKDEDOEGOH",
|
|
550
|
+
publicKeySha256: "da33611149dff3ab715be8671c0550bb8aec6112983b2473db9ee7afb829c110"
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
]);
|
|
554
|
+
function adyenKeyHost(environment) {
|
|
555
|
+
return typeof environment === "string" && /^(?:test|live(?:-[a-z]+)?)$/.test(environment) ? `checkoutshopper-${environment}.adyen.com` : null;
|
|
556
|
+
}
|
|
557
|
+
function wireProjection(profiles) {
|
|
558
|
+
return deepFreeze(profiles.map(({ adyen, ...profile }) => ({
|
|
559
|
+
...profile,
|
|
560
|
+
adyen: { environment: adyen.environment, clientKey: adyen.clientKey, keyHost: adyenKeyHost(adyen.environment) }
|
|
561
|
+
})));
|
|
562
|
+
}
|
|
563
|
+
var MERCHANT_PROFILE_RECOGNIZERS = /* @__PURE__ */ wireProjection(MERCHANT_HOSTED_PROFILES);
|
|
564
|
+
var MERCHANT_PROFILE_FACT_KINDS = /* @__PURE__ */ deepFreeze(["brand", "bin6", "last4", "masked_last4", "is_amex"]);
|
|
565
|
+
var CARD_BRANDS = /* @__PURE__ */ deepFreeze([
|
|
566
|
+
"visa",
|
|
567
|
+
"mastercard",
|
|
568
|
+
"amex",
|
|
569
|
+
"discover",
|
|
570
|
+
"diners",
|
|
571
|
+
"jcb",
|
|
572
|
+
"unionpay",
|
|
573
|
+
"maestro",
|
|
574
|
+
"mir",
|
|
575
|
+
"elo",
|
|
576
|
+
"hipercard",
|
|
577
|
+
"hiper"
|
|
578
|
+
]);
|
|
579
|
+
var BRAND_CODE = /^[A-Za-z0-9][A-Za-z0-9 ._-]{0,31}$/;
|
|
580
|
+
var MAX_FACTS = 8;
|
|
581
|
+
function factRefusal(entry, i, holder, besideHolder, reviewed) {
|
|
582
|
+
const label = `fact ${i}`;
|
|
583
|
+
if (!isPlainObject(entry))
|
|
584
|
+
return `${label} is not an object`;
|
|
585
|
+
const members = Object.keys(entry);
|
|
586
|
+
if (!members.includes("path") || !members.includes("fact") || !members.every((member) => ["path", "fact", "map"].includes(member))) {
|
|
587
|
+
return `${label} carries members other than path, fact and map`;
|
|
588
|
+
}
|
|
589
|
+
if (!MERCHANT_PROFILE_FACT_KINDS.includes(entry.fact))
|
|
590
|
+
return `${label} names no fact Agentcard lets leave (${String(entry.fact)})`;
|
|
591
|
+
const path = entry.path;
|
|
592
|
+
if (!isJsonPath(path) || path.length === 0 || typeof path[path.length - 1] !== "string")
|
|
593
|
+
return `${label}'s path is not a JSON path ending in a key`;
|
|
594
|
+
const key = path[path.length - 1];
|
|
595
|
+
const parent = path.slice(0, -1);
|
|
596
|
+
const inHolder = sameSteps(parent, holder);
|
|
597
|
+
if (!inHolder && !(besideHolder && sameSteps(parent, besideHolder) && key !== holder[holder.length - 1])) {
|
|
598
|
+
return `${label}'s path ${describePath(path)} is neither in the holder nor beside it`;
|
|
599
|
+
}
|
|
600
|
+
if (inHolder && reviewed.written.includes(key))
|
|
601
|
+
return `${label} writes over an encrypted field or a removed key`;
|
|
602
|
+
if (reviewed.read.some((read) => pathsOverlap(read, path)))
|
|
603
|
+
return `${label} writes where a body rule reads`;
|
|
604
|
+
if (entry.fact !== "brand")
|
|
605
|
+
return members.includes("map") ? `${label}: only a brand fact takes a map` : null;
|
|
606
|
+
const map = entry.map;
|
|
607
|
+
if (!isPlainObject(map))
|
|
608
|
+
return `${label}: a brand fact needs the merchant's brand map`;
|
|
609
|
+
const pairs = Object.entries(map);
|
|
610
|
+
if (pairs.length === 0 || !pairs.every(([brand, code]) => CARD_BRANDS.includes(brand) && typeof code === "string" && BRAND_CODE.test(code))) {
|
|
611
|
+
return `${label}'s brand map is not Agentcard card brands to merchant codes`;
|
|
612
|
+
}
|
|
613
|
+
return null;
|
|
614
|
+
}
|
|
615
|
+
function merchantProfileFactsRefusal(profile) {
|
|
616
|
+
if (!isPlainObject(profile))
|
|
617
|
+
return "the profile is not an object";
|
|
618
|
+
const facts = profile.facts;
|
|
619
|
+
if (facts === void 0)
|
|
620
|
+
return null;
|
|
621
|
+
if (!Array.isArray(facts) || facts.length > MAX_FACTS)
|
|
622
|
+
return `facts is not a list of at most ${MAX_FACTS}`;
|
|
623
|
+
const holder = profile.holder;
|
|
624
|
+
if (!isJsonPath(holder))
|
|
625
|
+
return "the holder is not a JSON path";
|
|
626
|
+
const besideHolder = holder.length > 0 && typeof holder[holder.length - 1] === "string" ? holder.slice(0, -1) : null;
|
|
627
|
+
const list = (value) => Array.isArray(value) ? value : [];
|
|
628
|
+
const reviewed = {
|
|
629
|
+
written: [...isPlainObject(profile.fields) ? Object.values(profile.fields) : [], ...list(profile.remove)],
|
|
630
|
+
read: [...list(profile.requires), ...list(profile.refuse)].map((rule) => rule?.path).concat([profile.amount], merchantAmountCardPaths(profile)).filter(Array.isArray)
|
|
631
|
+
};
|
|
632
|
+
for (const [i, entry] of facts.entries()) {
|
|
633
|
+
const refusal = factRefusal(entry, i, holder, besideHolder, reviewed);
|
|
634
|
+
if (refusal)
|
|
635
|
+
return refusal;
|
|
636
|
+
const earlier = facts.slice(0, i).find((other) => isPlainObject(other) && Array.isArray(other.path) && pathsOverlap(other.path, entry.path));
|
|
637
|
+
if (earlier)
|
|
638
|
+
return `fact ${i} writes where another fact writes`;
|
|
639
|
+
}
|
|
640
|
+
return null;
|
|
641
|
+
}
|
|
642
|
+
function merchantProfileRecognizerById(id) {
|
|
643
|
+
return typeof id === "string" ? MERCHANT_PROFILE_RECOGNIZERS.find((entry) => entry.id === id) ?? null : null;
|
|
644
|
+
}
|
|
645
|
+
function sameKeyIgnoringCase(key, step) {
|
|
646
|
+
return !FORBIDDEN_KEYS.has(key) && (key.toLowerCase() === step.toLowerCase() || key.toUpperCase() === step.toUpperCase());
|
|
647
|
+
}
|
|
648
|
+
function valuesIgnoringCase(root, path) {
|
|
649
|
+
let nodes = [root];
|
|
650
|
+
for (const step of path) {
|
|
651
|
+
const next = [];
|
|
652
|
+
for (const node of nodes) {
|
|
653
|
+
if (typeof step === "number") {
|
|
654
|
+
if (Array.isArray(node) && Number.isSafeInteger(step) && step >= 0 && step < node.length)
|
|
655
|
+
next.push(node[step]);
|
|
656
|
+
}
|
|
657
|
+
else if (isPlainObject(node)) {
|
|
658
|
+
for (const key of Object.keys(node))
|
|
659
|
+
if (sameKeyIgnoringCase(key, step))
|
|
660
|
+
next.push(node[key]);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
nodes = next;
|
|
664
|
+
}
|
|
665
|
+
return nodes;
|
|
666
|
+
}
|
|
667
|
+
function merchantBodyRefusal(profile, body) {
|
|
668
|
+
const p = merchantProfileRecognizerById(typeof profile === "string" ? profile : profile?.id);
|
|
669
|
+
if (!p)
|
|
670
|
+
throw new Error("Refusing to pay: unknown merchant profile.");
|
|
671
|
+
let parsed;
|
|
672
|
+
try {
|
|
673
|
+
parsed = typeof body === "string" ? JSON.parse(body) : void 0;
|
|
674
|
+
}
|
|
675
|
+
catch {
|
|
676
|
+
return "the paused body is not JSON";
|
|
677
|
+
}
|
|
678
|
+
if (!isPlainObject(parsed))
|
|
679
|
+
return "the paused body is not a JSON object";
|
|
680
|
+
if (!roundTrips(parsed, body))
|
|
681
|
+
return "the paused body does not round-trip through JSON, so its rules cannot be read";
|
|
682
|
+
for (const rule of p.requires) {
|
|
683
|
+
const at = jsonAt(parsed, rule.path);
|
|
684
|
+
if (!at.found || at.value !== rule.equals || valuesIgnoringCase(parsed, rule.path).some((value) => value !== rule.equals)) {
|
|
685
|
+
return `the paused body is not ${p.merchant}'s card request: ${describePath(rule.path)} is not ${JSON.stringify(rule.equals)}`;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
for (const rule of p.refuse) {
|
|
689
|
+
const values = valuesIgnoringCase(parsed, rule.path);
|
|
690
|
+
const fires = rule.when === "true" ? values.some((value) => value !== false && value !== null) : values.length > 0;
|
|
691
|
+
if (fires)
|
|
692
|
+
return `the paused body asks ${p.merchant} for more than this purchase: ${describePath(rule.path)}`;
|
|
693
|
+
}
|
|
694
|
+
return null;
|
|
695
|
+
}
|
|
696
|
+
// src/substitutions.js
|
|
15
697
|
function substituteEncryptedFields(body, sub) {
|
|
16
698
|
if (!sub || sub.encoding !== "json") {
|
|
17
699
|
throw new SubstitutionError(`unsupported substitution encoding: ${String(sub?.encoding)}`);
|
|
@@ -63,4 +745,74 @@ function substituteEncryptedFields(body, sub) {
|
|
|
63
745
|
delete node[key];
|
|
64
746
|
return JSON.stringify(parsed);
|
|
65
747
|
}
|
|
66
|
-
|
|
748
|
+
var MERCHANT_HOSTED_MEMBERS = /* @__PURE__ */ new Set(["encoding", "kind", "profile", "path", "fields", "remove", "set"]);
|
|
749
|
+
var FACT_VALUE = {
|
|
750
|
+
brand: (value, fact) => typeof value === "string" && Object.values(fact.map).includes(value),
|
|
751
|
+
bin6: (value) => typeof value === "string" && /^\d{6}$/.test(value),
|
|
752
|
+
last4: (value) => typeof value === "string" && /^\d{4}$/.test(value),
|
|
753
|
+
masked_last4: (value) => typeof value === "string" && /^\*{12}\d{4}$/.test(value),
|
|
754
|
+
is_amex: (value) => typeof value === "boolean"
|
|
755
|
+
};
|
|
756
|
+
function checkedFactSet(set, facts, merchant) {
|
|
757
|
+
const list = Array.isArray(set) ? [...set] : null;
|
|
758
|
+
if (!list || list.length !== facts.length) {
|
|
759
|
+
throw new SubstitutionError(`the substitution does not carry exactly the card facts ${merchant}'s card request takes`);
|
|
760
|
+
}
|
|
761
|
+
return facts.map((fact, i) => {
|
|
762
|
+
const entry = isPlainObject(list[i]) ? Object.fromEntries(Object.entries(list[i])) : null;
|
|
763
|
+
if (!entry || Object.keys(entry).sort().join(",") !== "path,value") {
|
|
764
|
+
throw new SubstitutionError(`card fact ${i} is not a {path, value}`);
|
|
765
|
+
}
|
|
766
|
+
if (!sameSteps(entry.path, fact.path)) {
|
|
767
|
+
throw new SubstitutionError(`card fact ${i} is not at a path ${merchant}'s card request declares`);
|
|
768
|
+
}
|
|
769
|
+
if (!Object.prototype.hasOwnProperty.call(FACT_VALUE, fact.fact) || !FACT_VALUE[fact.fact](entry.value, fact)) {
|
|
770
|
+
throw new SubstitutionError(`card fact ${i} is not a ${fact.fact} value ${merchant} takes`);
|
|
771
|
+
}
|
|
772
|
+
return { path: [...fact.path], value: entry.value };
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
function substituteMerchantHosted(body, sub, opts) {
|
|
776
|
+
if (!isPlainObject(sub) || sub.encoding !== "json") {
|
|
777
|
+
throw new SubstitutionError(`unsupported substitution encoding: ${String(sub?.encoding)}`);
|
|
778
|
+
}
|
|
779
|
+
if (sub.kind !== "merchant_hosted")
|
|
780
|
+
throw new SubstitutionError(`unsupported substitution kind: ${String(sub.kind)}`);
|
|
781
|
+
for (const member of Object.keys(sub)) {
|
|
782
|
+
if (!MERCHANT_HOSTED_MEMBERS.has(member))
|
|
783
|
+
throw new SubstitutionError(`unexpected substitution member ${member}`);
|
|
784
|
+
}
|
|
785
|
+
const profile = merchantProfileRecognizerById(sub.profile);
|
|
786
|
+
if (!profile)
|
|
787
|
+
throw new SubstitutionError("the substitution names no merchant profile Agentcard reviewed");
|
|
788
|
+
if (!sameSteps(sub.path, profile.holder)) {
|
|
789
|
+
throw new SubstitutionError(`the substitution path is not where ${profile.merchant}'s card request holds the card`);
|
|
790
|
+
}
|
|
791
|
+
const fields = isPlainObject(sub.fields) ? Object.fromEntries(Object.entries(sub.fields)) : {};
|
|
792
|
+
const names = Object.keys(fields);
|
|
793
|
+
const reviewed = Object.values(profile.fields);
|
|
794
|
+
if (names.length !== reviewed.length || !reviewed.every((name) => names.includes(name))) {
|
|
795
|
+
throw new SubstitutionError(`the substitution fields are not ${profile.merchant}'s four encrypted card fields`);
|
|
796
|
+
}
|
|
797
|
+
if (!sameSteps(sub.remove, profile.remove)) {
|
|
798
|
+
throw new SubstitutionError(`the substitution removes other keys than ${profile.merchant}'s reviewed ones`);
|
|
799
|
+
}
|
|
800
|
+
const factsRefusal = merchantProfileFactsRefusal(profile);
|
|
801
|
+
if (factsRefusal)
|
|
802
|
+
throw new SubstitutionError(`${profile.merchant}'s card facts cannot be written: ${factsRefusal}`);
|
|
803
|
+
const facts = profile.facts ?? [];
|
|
804
|
+
const declaresSet = Object.prototype.hasOwnProperty.call(sub, "set");
|
|
805
|
+
if (facts.length === 0 && declaresSet) {
|
|
806
|
+
throw new SubstitutionError(`unexpected substitution member set: ${profile.merchant}'s card request takes no card facts`);
|
|
807
|
+
}
|
|
808
|
+
const set = facts.length === 0 ? [] : checkedFactSet(sub.set, facts, profile.merchant);
|
|
809
|
+
const refusal = merchantBodyRefusal(profile.id, body);
|
|
810
|
+
if (refusal)
|
|
811
|
+
throw new SubstitutionError(refusal);
|
|
812
|
+
const out = swapMerchantHostedFields(body, { path: profile.holder, fields, remove: profile.remove, ...set.length ? { set } : {} });
|
|
813
|
+
if (profile.status !== "enabled" && opts?.sandboxDeclared !== true) {
|
|
814
|
+
throw new SubstitutionError(`Agentcard has not turned on payments to ${profile.merchant} yet`);
|
|
815
|
+
}
|
|
816
|
+
return out;
|
|
817
|
+
}
|
|
818
|
+
export { SubstitutionError, substituteEncryptedFields, substituteMerchantHosted };
|