@agent-cards/checkout 0.19.0 → 0.22.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.
Files changed (106) hide show
  1. package/README.md +6 -753
  2. package/cdp.d.ts +1 -0
  3. package/cdp.js +2 -0
  4. package/index.d.ts +1 -0
  5. package/index.js +2 -0
  6. package/package.json +33 -33
  7. package/playwright.d.ts +1 -0
  8. package/playwright.js +2 -0
  9. package/preflight.d.ts +1 -0
  10. package/preflight.js +2 -0
  11. package/CHANGELOG.md +0 -132
  12. package/PREFLIGHT.md +0 -312
  13. package/dist/adyen-merchant-hosted.generated.d.ts +0 -277
  14. package/dist/adyen-merchant-hosted.generated.js +0 -1902
  15. package/dist/adyen.generated.d.ts +0 -24
  16. package/dist/adyen.generated.js +0 -64
  17. package/dist/attachment.d.ts +0 -11
  18. package/dist/attachment.js +0 -50
  19. package/dist/braintree.d.ts +0 -2
  20. package/dist/braintree.generated.d.ts +0 -10
  21. package/dist/braintree.generated.js +0 -302
  22. package/dist/braintree.js +0 -2
  23. package/dist/builtin-registry.generated.d.ts +0 -2
  24. package/dist/builtin-registry.generated.js +0 -1
  25. package/dist/card-fields.generated.d.ts +0 -3
  26. package/dist/card-fields.generated.js +0 -46
  27. package/dist/cdp.d.ts +0 -192
  28. package/dist/cdp.js +0 -2393
  29. package/dist/checkout-com.generated.d.ts +0 -4
  30. package/dist/checkout-com.generated.js +0 -183
  31. package/dist/client.d.ts +0 -849
  32. package/dist/client.js +0 -1754
  33. package/dist/cse-body.d.ts +0 -25
  34. package/dist/cse-body.js +0 -41
  35. package/dist/fiserv.d.ts +0 -65
  36. package/dist/fiserv.generated.d.ts +0 -73
  37. package/dist/fiserv.generated.js +0 -830
  38. package/dist/fiserv.js +0 -104
  39. package/dist/hosted-form.d.ts +0 -44
  40. package/dist/hosted-form.js +0 -78
  41. package/dist/index.d.ts +0 -18
  42. package/dist/index.js +0 -10
  43. package/dist/lifecycle.d.ts +0 -179
  44. package/dist/lifecycle.js +0 -395
  45. package/dist/mercado-checkout.d.ts +0 -20
  46. package/dist/mercado-checkout.generated.d.ts +0 -52
  47. package/dist/mercado-checkout.generated.js +0 -198
  48. package/dist/mercado-checkout.js +0 -108
  49. package/dist/merchant-handoff.d.ts +0 -54
  50. package/dist/merchant-handoff.js +0 -100
  51. package/dist/merchant-hosted.d.ts +0 -140
  52. package/dist/merchant-hosted.js +0 -170
  53. package/dist/merchant-total-watch.d.ts +0 -115
  54. package/dist/merchant-total-watch.js +0 -268
  55. package/dist/merchant-total.d.ts +0 -257
  56. package/dist/merchant-total.js +0 -383
  57. package/dist/owned-shop.generated.d.ts +0 -24
  58. package/dist/owned-shop.generated.js +0 -108
  59. package/dist/paysafe.generated.d.ts +0 -12
  60. package/dist/paysafe.generated.js +0 -87
  61. package/dist/playwright.d.ts +0 -3
  62. package/dist/playwright.js +0 -3
  63. package/dist/pre-claim.d.ts +0 -123
  64. package/dist/pre-claim.js +0 -386
  65. package/dist/preflight-capabilities.generated.d.ts +0 -1253
  66. package/dist/preflight-capabilities.generated.js +0 -1929
  67. package/dist/preflight-catalog.json +0 -4727
  68. package/dist/preflight-playwright.d.ts +0 -34
  69. package/dist/preflight-playwright.js +0 -355
  70. package/dist/preflight-schemas.json +0 -1122
  71. package/dist/preflight.d.ts +0 -1
  72. package/dist/preflight.generated.d.ts +0 -1965
  73. package/dist/preflight.generated.js +0 -570
  74. package/dist/preflight.js +0 -2
  75. package/dist/preparation.d.ts +0 -38
  76. package/dist/preparation.js +0 -191
  77. package/dist/prepared-processor.d.ts +0 -43
  78. package/dist/prepared-processor.js +0 -172
  79. package/dist/recurly.generated.d.ts +0 -1
  80. package/dist/recurly.generated.js +0 -87
  81. package/dist/registry.d.ts +0 -121
  82. package/dist/registry.js +0 -310
  83. package/dist/spreedly.generated.d.ts +0 -10
  84. package/dist/spreedly.generated.js +0 -332
  85. package/dist/stripe-checkout.d.ts +0 -81
  86. package/dist/stripe-checkout.generated.d.ts +0 -82
  87. package/dist/stripe-checkout.generated.js +0 -1093
  88. package/dist/stripe-checkout.js +0 -140
  89. package/dist/substitute.d.ts +0 -38
  90. package/dist/substitute.js +0 -23
  91. package/dist/substitutions.generated.d.ts +0 -11
  92. package/dist/substitutions.generated.js +0 -818
  93. package/examples/existing-browser.mjs +0 -63
  94. package/examples/preflight/classify-direct.mjs +0 -21
  95. package/examples/preflight/classify-kernel.mjs +0 -30
  96. package/examples/preflight/inspect-browser.mjs +0 -44
  97. package/examples/preflight/kernel-native/README.md +0 -112
  98. package/examples/preflight/kernel-native/documented-adapters.json +0 -113
  99. package/examples/preflight/kernel-native/inventory.json +0 -233
  100. package/examples/preflight/kernel-native/qualification.mjs +0 -182
  101. package/examples/preflight/kernel-profile.empty.json +0 -11
  102. package/examples/preflight/mollie-hosted.observations.json +0 -23
  103. package/examples/preflight/mollie-hosted.result.json +0 -103
  104. package/examples/preflight/stripe-script.direct.result.json +0 -92
  105. package/examples/preflight/stripe-script.observations.json +0 -16
  106. package/examples/preflight/stripe-script.result.json +0 -87
package/dist/client.js DELETED
@@ -1,1754 +0,0 @@
1
- import { BUILTIN_REGISTRY, cardUrlPatterns as deriveCardUrlPatterns, findRecognizer, recognizerRequiresPreparation, } from './registry.js';
2
- import { armServedProfiles, classifyProfileRequest, declareSandboxMerchant, merchantProfileEnvironment, merchantProfileFor, merchantProfileUrlPatterns, } from './merchant-hosted.js';
3
- import { merchantAmountExchanges, merchantAmountFromResponses, merchantProfilePricedBySource, merchantTotalWire, MerchantTotalWait, } from './merchant-total.js';
4
- import { isMercadoTokenRequest, parseMercadoCheckoutContext, validateMercadoProcessorContext } from './mercado-checkout.generated.js';
5
- import { matchesPreparation, preparationMode, validPreparationEnvironment } from './prepared-processor.js';
6
- import { fiservKeyPinById, isFiservSubstitution, requiresFiservPreparation } from './fiserv.js';
7
- import { hasOwnedShopMarker, parseOwnedShopOrder, parseOwnedShopReceipt } from './owned-shop.generated.js';
8
- import { requestWithoutCard } from './card-fields.generated.js';
9
- import { classifyStripeCheckoutRequest, encodeStripeCheckoutContext, hasStripeCheckoutMarker, parseStripeCheckoutContext, parseStripeCheckoutResponse, STRIPE_CHECKOUT_CONTEXT_HEADER, } from './stripe-checkout.generated.js';
10
- /**
11
- * The modes this SDK can finish. Asked for on syncRegistry (the API serves
12
- * only recognizers in these modes, so a request this build cannot complete
13
- * is never paused) and sent on every create.
14
- */
15
- export const SUPPORTED_MODES = ['token', 'cse', 'hosted_form'];
16
- /**
17
- * Registry features this SDK honours, asked for on syncRegistry next to the
18
- * modes. `card_fields`: it reads a recognizer's cardFields and claims only a
19
- * request whose body carries the card, so the API may serve it recognizers
20
- * whose endpoints also run without a card. `checkout_sessions`: it lets a
21
- * request the recognizer marks as routine without a card (passWithoutCard)
22
- * through ahead of its holds, so the API may serve Stripe's Checkout Session
23
- * confirm, which hosted Checkout sends after an approval.
24
- */
25
- export const SUPPORTED_REGISTRY_FEATURES = ['card_fields', 'checkout_sessions'];
26
- /**
27
- * Dark-launch capabilities this build can finish. Sent on syncRegistry as
28
- * ?capabilities= so the API also serves the recognizers gated behind one of
29
- * these. `fiserv_card_capture`: Fiserv Commerce Hub's Secure Data Capture card
30
- * capture, which this build pauses and pays only through a prepared checkout
31
- * (pre-claim.ts, fiserv.ts). The API serves Fiserv's recognizer only to a build
32
- * that lists it and only for a company Agentcard turned Fiserv on for, so an older
33
- * SDK never pauses a capture it cannot finish and no SDK pauses one for a company
34
- * Fiserv is off for; the API also refuses a Fiserv payment for every such company.
35
- */
36
- export const SUPPORTED_CAPABILITIES = ['fiserv_card_capture'];
37
- /** An integer in the smallest unit, or a decimal string in normal units with a point; nothing else. */
38
- export function validAmountInput(amount) {
39
- if (typeof amount === 'number')
40
- return Number.isSafeInteger(amount) && amount >= 0;
41
- return typeof amount === 'string' && /^\d{1,15}\.\d{1,3}$/.test(amount.trim());
42
- }
43
- const AMOUNT_AUTHORITIES = ['processor', 'agent', 'page', 'none'];
44
- export class CheckoutPreparationError extends Error {
45
- preparationId;
46
- reason;
47
- constructor(preparationId, reason) {
48
- super(`Checkout preparation unavailable: ${reason}`);
49
- this.preparationId = preparationId;
50
- this.reason = reason;
51
- this.name = 'CheckoutPreparationError';
52
- }
53
- }
54
- export class CardEncryptedError extends Error {
55
- psp;
56
- constructor(psp) {
57
- super(`${psp} encrypts the card in-page; a paused request carries a blob, not a PAN. ` +
58
- `Agentcard must run this PSP's client-side crypto in the vault.`);
59
- this.psp = psp;
60
- this.name = 'CardEncryptedError';
61
- }
62
- }
63
- /**
64
- * The registry requests a mode this SDK build cannot finish, before creation.
65
- * A response in an unexpected mode after creation has an unknown payment
66
- * outcome instead and raises PaymentOutcomeUnknownError.
67
- */
68
- export class UnsupportedModeError extends Error {
69
- mode;
70
- constructor(mode) {
71
- super(`the checkout requests mode "${mode}", which this version of @agent-cards/checkout cannot complete; upgrade the SDK.`);
72
- this.mode = mode;
73
- this.name = 'UnsupportedModeError';
74
- }
75
- }
76
- /**
77
- * The recognizer says this processor is preparation-required, and no preparation
78
- * was passed. Its card may be sent only after the cardholder approved a
79
- * prepare(): call prepareCheckout() (or the adapters' preparation gate) before
80
- * this request is intercepted. Thrown locally before any create or prompt, and
81
- * terminal (retrying the same paused request without preparing fails the same
82
- * way). The API answers 409 preparation_required for the same case.
83
- */
84
- export class PreparationRequiredError extends Error {
85
- psp;
86
- constructor(psp) {
87
- super(`${psp} requires a prepared checkout: run prepareCheckout() and have the cardholder approve it before this request is taken over. Nothing was charged.`);
88
- this.psp = psp;
89
- this.name = 'PreparationRequiredError';
90
- }
91
- }
92
- export class ApprovalTimeoutError extends Error {
93
- constructor(ms) { super(`user did not approve within ${ms}ms`); this.name = 'ApprovalTimeoutError'; }
94
- }
95
- export class CheckoutCancelledError extends Error {
96
- constructor() { super('checkout cancelled locally before authorization creation'); this.name = 'CheckoutCancelledError'; }
97
- }
98
- export const RUNTIME_CANCEL_REASONS = ['merchant_request_aborted', 'merchant_never_retried'];
99
- /** The payment may have reached the processor. Reconcile the merchant order before any new attempt. */
100
- export class PaymentOutcomeUnknownError extends Error {
101
- authorizationId;
102
- reason;
103
- constructor(authorizationId, reason) {
104
- super(`payment outcome unknown${authorizationId ? ` for ${authorizationId}` : ''}: ${reason}; check the merchant order before retrying`);
105
- this.authorizationId = authorizationId;
106
- this.reason = reason;
107
- this.name = 'PaymentOutcomeUnknownError';
108
- }
109
- }
110
- export class ApprovalDeclinedError extends Error {
111
- constructor(reason) { super(`user declined: ${reason}`); this.name = 'ApprovalDeclinedError'; }
112
- }
113
- /**
114
- * Agentcard refused the payment because the processor's amount did not match
115
- * the amount the user was (or would have been) asked to approve. Nothing was
116
- * charged. Two stages:
117
- * - 'create': the intent already disagreed when the request was parked. No
118
- * authorization exists (`authorizationId` is null). Per request, not per
119
- * page: the merchant can still update the intent before confirmation, so
120
- * the adapters keep intercepting and the next attempt is judged afresh.
121
- * - 'pre_replay': the intent moved between create and the moment the
122
- * cardholder's device would have sent the card. The authorization is
123
- * `declined` with reason `amount_mismatch`.
124
- *
125
- * A decline in every structural sense (the adapters abort the paused request
126
- * and quiet the page's retry exactly as for a person's "no"), so it extends
127
- * ApprovalDeclinedError: code that already handles declines keeps working,
128
- * and code that wants the numbers reads them here or branches on `code`.
129
- *
130
- * A merchant-hosted checkout (an Adyen merchant whose own server charges the card)
131
- * has no processor read-back: its agent amount is held to the merchant's own
132
- * checkout total, as the agent's browser read it, at stage 'create'. The SDK refuses
133
- * that before any create, and the API refuses the same with 409 `amount_mismatch`, so a
134
- * caller catches this one class either way; `amountSource` says whose number
135
- * `actualCents` is, and the message names it.
136
- */
137
- export class AmountMismatchError extends ApprovalDeclinedError {
138
- authorizationId;
139
- expectedCents;
140
- actualCents;
141
- currency;
142
- actualCurrency;
143
- stage;
144
- amountSource;
145
- code = 'amount_mismatch';
146
- constructor(
147
- /** The declined authorization, or null for a create-time refusal (no row exists). */
148
- authorizationId,
149
- /** What the user was asked to approve, smallest currency unit. */
150
- expectedCents,
151
- /** What the processor (or the merchant, see `amountSource`) reported at the last check. */
152
- actualCents,
153
- /** ISO 4217 of the approved amount. */
154
- currency,
155
- /** ISO 4217 the processor reported (differs only on a currency change). */
156
- actualCurrency = currency,
157
- /** Which check refused it. */
158
- stage = 'pre_replay',
159
- /**
160
- * Whose number `actualCents` is: the processor's ('processor'), a merchant-hosted
161
- * merchant's own checkout total ('merchant_total'), or the amount its card request
162
- * names ('merchant_request').
163
- */
164
- amountSource = 'processor') {
165
- super('amount_mismatch');
166
- this.authorizationId = authorizationId;
167
- this.expectedCents = expectedCents;
168
- this.actualCents = actualCents;
169
- this.currency = currency;
170
- this.actualCurrency = actualCurrency;
171
- this.stage = stage;
172
- this.amountSource = amountSource;
173
- this.name = 'AmountMismatchError';
174
- const reported = amountSource === 'merchant_total' ? 'the merchant\'s own checkout total is'
175
- : amountSource === 'merchant_request' ? 'the merchant\'s card request names' : 'the processor reports';
176
- this.message = `amount mismatch${authorizationId ? ` on ${authorizationId}` : ' at create'}: `
177
- + `the user was asked to approve ${expectedCents} ${currency}, ${reported} ${actualCents} ${actualCurrency}. Nothing was charged.`;
178
- }
179
- }
180
- /**
181
- * The PaymentIntent behind this checkout can no longer be confirmed: it was
182
- * already charged (succeeded), is being charged (processing), is authorized
183
- * and on hold for the merchant to capture (requires_capture), or was
184
- * canceled. Agentcard refused rather than replay a confirm at it; the
185
- * authorization is `declined` with reason `intent_not_confirmable`.
186
- * Deliberately NOT "nothing was charged": for three of those four, money has
187
- * moved or is moving. Check the intent at Stripe before retrying.
188
- */
189
- export class IntentNotConfirmableError extends ApprovalDeclinedError {
190
- authorizationId;
191
- code = 'intent_not_confirmable';
192
- constructor(authorizationId) {
193
- super('intent_not_confirmable');
194
- this.authorizationId = authorizationId;
195
- this.name = 'IntentNotConfirmableError';
196
- this.message = `the PaymentIntent behind ${authorizationId} was already charged, is processing, or is on hold; `
197
- + 'it cannot be confirmed again. Check the intent at Stripe before retrying.';
198
- }
199
- }
200
- function parseProcessorError(value) {
201
- if (!value || typeof value !== 'object' || Array.isArray(value))
202
- return null;
203
- const fields = value;
204
- const result = {};
205
- const exact = (pattern, field) => pattern.exec(field)?.[0] === field;
206
- for (const [key, field] of Object.entries(fields)) {
207
- if (typeof field !== 'string')
208
- return null;
209
- if (key === 'reason' || key === 'source' || key === 'step') {
210
- if (!exact(/^[a-z][a-z_]{0,63}$/, field))
211
- return null;
212
- result[key] = field;
213
- }
214
- else if (key === 'payment_id' || key === 'order_id') {
215
- if (!exact(key === 'payment_id' ? /^pay_[A-Za-z0-9]{1,128}$/ : /^order_[A-Za-z0-9]{1,128}$/, field))
216
- return null;
217
- result[key] = field;
218
- }
219
- else
220
- return null;
221
- }
222
- return Object.keys(result).length ? result : null;
223
- }
224
- /**
225
- * The device reported a processor request rejection. A generic processor
226
- * code does not establish an issuer decline or prove no money moved. Read
227
- * the bounded processor evidence and reconcile the merchant before retrying.
228
- * The authorization remains `declined` with reason `processor_refused` for
229
- * compatibility; no successful processor response is handed to the merchant.
230
- */
231
- export class ProcessorRefusedError extends ApprovalDeclinedError {
232
- authorizationId;
233
- pspErrorCode;
234
- code = 'processor_refused';
235
- processorError;
236
- constructor(authorizationId, pspErrorCode, processorError = null) {
237
- super('processor_refused');
238
- this.authorizationId = authorizationId;
239
- this.pspErrorCode = pspErrorCode;
240
- this.name = 'ProcessorRefusedError';
241
- this.processorError = parseProcessorError(processorError);
242
- this.message = `the processor rejected the payment request on ${authorizationId}${pspErrorCode ? ` (${pspErrorCode})` : ''}. Check the merchant payment status before retrying.`;
243
- }
244
- }
245
- const ADYEN_TEST_PLATFORM_REFUSALS = ['adyen_test_environment_refused', 'adyen_test_platform_requires_documented_test_card'];
246
- /**
247
- * Agentcard refused a payment on Adyen's TEST platform, where a test account can
248
- * read whatever is encrypted under its key. Nothing was encrypted and nothing was
249
- * charged. `code` says which rule:
250
- * - 'adyen_test_environment_refused': a live checkout names Adyen's test host or
251
- * a `test_` client key. Stage 'create': the API refused it before anyone was
252
- * asked (`authorizationId` is null) and the page's next request is refused the
253
- * same way, so the adapters stop intercepting. Stage 'pre_replay': the
254
- * authorization was declined right before the card would have been encrypted.
255
- * - 'adyen_test_platform_requires_documented_test_card': a test-mode checkout on
256
- * Adyen's test platform, where the approval page encrypts only Adyen's
257
- * documented test cards and the cardholder's card is not one.
258
- * A decline in every structural sense, so it extends ApprovalDeclinedError.
259
- */
260
- export class AdyenTestPlatformRefusedError extends ApprovalDeclinedError {
261
- authorizationId;
262
- code;
263
- stage;
264
- constructor(authorizationId, code, stage) {
265
- super(code);
266
- this.authorizationId = authorizationId;
267
- this.code = code;
268
- this.stage = stage;
269
- this.name = 'AdyenTestPlatformRefusedError';
270
- this.message = code === 'adyen_test_environment_refused'
271
- ? `Adyen's test platform cannot take this live payment${authorizationId ? ` (${authorizationId} was declined)` : '; no authorization was created'}: the checkout names Adyen's test host or a test_ client key. Nothing was encrypted or charged.`
272
- : `this checkout is on Adyen's test platform, which takes only Adyen's documented test cards${authorizationId ? ` (${authorizationId} was declined)` : ''}. Nothing was encrypted or charged.`;
273
- }
274
- }
275
- /**
276
- * A reviewed merchant's own checkout total could not stand behind this payment, so the
277
- * card request was held and nothing was charged. The merchant's server picks what it
278
- * charges, so a merchant-hosted payment is priced by the total the merchant's own
279
- * checkout responses named in this browser (the profile's amount source), never by the
280
- * agent's number alone. `code`:
281
- * - 'merchant_total_required': no total was sent (stage 'sdk': this runtime recorded no
282
- * merchant responses; stage 'create': the API got none);
283
- * - 'merchant_total_refused': the responses do not confirm one total for this order
284
- * (`reasonCode`: no_source when none was seen, stale, refused, unreadable,
285
- * mismatch, unbound, invalid); stage 'sdk' before any create, 'create' by the API;
286
- * - 'merchant_total_changed': stage 'release': between the approval and the moment the
287
- * card would have gone out, the merchant's total moved, or a request that could move
288
- * it had not answered. The approval is retired; the card never left this browser;
289
- * - 'merchant_total_stale': stage 'runtime': the approval came after the total was too
290
- * old for its profile, so the API withheld the card.
291
- * A decline in every structural sense, so it extends ApprovalDeclinedError.
292
- */
293
- export class MerchantTotalError extends ApprovalDeclinedError {
294
- authorizationId;
295
- code;
296
- stage;
297
- reasonCode;
298
- constructor(authorizationId, code, stage, reasonCode, reason) {
299
- super(code);
300
- this.authorizationId = authorizationId;
301
- this.code = code;
302
- this.stage = stage;
303
- this.reasonCode = reasonCode;
304
- this.name = 'MerchantTotalError';
305
- this.message = `${reason}${authorizationId ? ` (${authorizationId})` : ''}. Nothing was charged.`;
306
- }
307
- }
308
- /**
309
- * A non-2xx from the Agentcard API, carrying the status so callers can tell a
310
- * misconfiguration from a blip. The adapters use this to decide whether
311
- * retrying is worth anything: a 404 `connection_not_found` will answer the same
312
- * way forever, while a 429 or a 502 will not.
313
- */
314
- /**
315
- * The company's preset refused the purchase. The company that runs this
316
- * integration put rules on its users' Vault purchases (a merchant list, a
317
- * currency, a spend cap, a time window); this purchase is outside them.
318
- * Nothing was charged. Two stages:
319
- * - 'create': refused before any authorization existed (`authorizationId`
320
- * is null); nobody was asked to approve. Per purchase, not per page: the
321
- * next request on the same page is judged afresh.
322
- * - 'pre_replay': refused right before the cardholder's device would have
323
- * sent the card; the authorization is `declined` with `code` as reason.
324
- * `code` is the rule's reason (merchant_denied, currency_denied,
325
- * spend_rate_exceeded, time_window_denied, ...), `message` the rule's own
326
- * statement with the company's next step, `preset` the version that judged
327
- * it. When several presets refused the same purchase, `code`, `preset`,
328
- * `rule` and `attachment` are the first, and `refusals` names every one. A
329
- * decline in every structural sense (the adapters quiet the page's retry as
330
- * for a person's "no"), so it extends ApprovalDeclinedError.
331
- */
332
- export class PresetRefusedError extends ApprovalDeclinedError {
333
- authorizationId;
334
- code;
335
- detail;
336
- preset;
337
- rule;
338
- stage;
339
- attachment;
340
- /** The preset's name. */
341
- presetName;
342
- /** Every preset that refused, each with its attachment, rule, code and statement; one entry when one refused. */
343
- refusals;
344
- constructor(authorizationId, code, detail, preset, rule, stage,
345
- /** The stored card the preset that refused is attached to, with its last four digits. */
346
- attachment = null, refusals = []) {
347
- super(code);
348
- this.authorizationId = authorizationId;
349
- this.code = code;
350
- this.detail = detail;
351
- this.preset = preset;
352
- this.rule = rule;
353
- this.stage = stage;
354
- this.attachment = attachment;
355
- this.name = 'PresetRefusedError';
356
- this.presetName = preset?.name ?? null;
357
- this.refusals = refusals.length ? refusals : preset ? [{ preset, attachment, rule, code, detail }] : [];
358
- const others = this.refusals.slice(1).map((r) => `"${r.preset.name}"`);
359
- const also = others.length ? ` Also refused by ${others.length === 1 ? 'preset' : 'presets'} ${others.join(', ')}.` : '';
360
- this.message = `refused by the company preset${preset ? ` "${preset.name}"` : ''} (${code}${stage === 'create' ? ', before any authorization was created' : ''}): ${detail ?? 'this purchase is outside the rules the company set'}${also}`;
361
- }
362
- }
363
- /** A decline reason only the company presets stamp (see PresetRefusedError). */
364
- export const PRESET_REFUSAL_REASONS = new Set([
365
- 'spend_total_exceeded', 'spend_rate_exceeded', 'spend_rate_unknown', 'amount_unknown', 'rate_unavailable',
366
- 'category_denied', 'category_unknown', 'merchant_denied', 'merchant_unknown',
367
- 'geo_denied', 'geo_unknown', 'currency_denied', 'currency_unknown',
368
- 'time_window_denied', 'surface_denied', 'surface_unknown',
369
- ]);
370
- function presetOf(v) {
371
- if (!v || typeof v !== 'object')
372
- return null;
373
- const p = v;
374
- if (typeof p.id !== 'string' || typeof p.version !== 'number' || typeof p.name !== 'string')
375
- return null;
376
- return { id: p.id, version: p.version, name: p.name };
377
- }
378
- function attachmentOf(v) {
379
- if (!v || typeof v !== 'object')
380
- return null;
381
- const a = v;
382
- if (a.kind !== 'card')
383
- return null;
384
- return { kind: a.kind, targetId: typeof a.target_id === 'string' ? a.target_id : null, last4: typeof a.last4 === 'string' ? a.last4 : null };
385
- }
386
- /** The `refusals` list of a refusal envelope: every entry that names a preset. */
387
- function refusalsOf(v) {
388
- if (!Array.isArray(v))
389
- return [];
390
- const out = [];
391
- for (const item of v) {
392
- if (!item || typeof item !== 'object')
393
- continue;
394
- const r = item;
395
- const preset = presetOf(r.preset);
396
- if (!preset || typeof r.reason !== 'string')
397
- continue;
398
- out.push({ preset, attachment: attachmentOf(r.attachment), rule: typeof r.rule === 'string' ? r.rule : null, code: r.reason, detail: typeof r.message === 'string' ? r.message : null });
399
- }
400
- return out;
401
- }
402
- export class CheckoutApiError extends Error {
403
- status;
404
- path;
405
- bodyText;
406
- /** The API's stable error code (`{ error: { code } }`), or null when the body carried none. */
407
- code;
408
- /**
409
- * The rest of the error envelope. A 409 `amount_mismatch` from create
410
- * carries `expected_cents`, `actual_cents`, `currency`, `actual_currency`;
411
- * an `amount_unverifiable` carries `reason`; an `intent_not_confirmable`
412
- * carries `intent_status`.
413
- */
414
- details;
415
- constructor(status, path, bodyText) {
416
- super(`agentcard ${path} -> ${status} ${bodyText}`);
417
- this.status = status;
418
- this.path = path;
419
- this.bodyText = bodyText;
420
- this.name = 'CheckoutApiError';
421
- const envelope = parseErrorEnvelope(bodyText);
422
- this.code = envelope.code;
423
- this.details = envelope.details;
424
- }
425
- /**
426
- * True when repeating this exact call cannot succeed: a misconfiguration
427
- * (4xx other than 429). NOT a 409 `amount_mismatch`: Stripe lets a merchant
428
- * update an intent's amount until it is confirmed, so the next request on
429
- * the same page may well agree. That one is a per-request failure, and
430
- * authorize() surfaces it as AmountMismatchError before an adapter ever
431
- * sees it here. NOT a 409 `duplicate_submission` either: the household
432
- * already has, or already answered, the prompt for this submission, and
433
- * once that prior authorization is declined or expired the same form is a
434
- * new question. The adapters quiet the page's re-post the way they quiet a
435
- * decline instead of latching the attachment.
436
- */
437
- get permanent() {
438
- if (this.code === 'amount_mismatch' || this.code === 'duplicate_submission')
439
- return false;
440
- // Nor a refusal by the company preset (a 403 carrying `preset` in the
441
- // envelope): the company's rules refused THIS purchase, at this merchant,
442
- // for this amount, at this hour. The next one on the same page may pass,
443
- // so the page is quieted like a decline, never latched.
444
- if (this.details.preset && typeof this.details.preset === 'object')
445
- return false;
446
- return this.status >= 400 && this.status < 500 && this.status !== 429;
447
- }
448
- }
449
- function parseErrorEnvelope(bodyText) {
450
- try {
451
- const parsed = JSON.parse(bodyText);
452
- const err = parsed && typeof parsed === 'object' ? parsed.error : null;
453
- if (err && typeof err === 'object') {
454
- const { code, ...details } = err;
455
- return { code: typeof code === 'string' ? code : null, details };
456
- }
457
- }
458
- catch {
459
- // Not a JSON envelope (a proxy page, an empty body): no code to carry.
460
- }
461
- return { code: null, details: {} };
462
- }
463
- /**
464
- * A URL as it may appear in an error message: origin + path only. A paused
465
- * request's URL can carry a client secret in its query string, and error
466
- * messages travel further than anyone intends (onEvent, logs, crash reports).
467
- */
468
- export function redactUrl(raw) {
469
- try {
470
- const u = new URL(raw);
471
- return `${u.origin}${u.pathname}`;
472
- }
473
- catch {
474
- return raw.split(/[?#]/)[0];
475
- }
476
- }
477
- /**
478
- * Refusals a preparation create answers before any preparation exists, surfaced
479
- * as CheckoutPreparationError reasons: Adyen's test platform on a live client, and
480
- * a merchant profile Agentcard has not turned on, does not offer this account, or
481
- * whose key or checkout page is not the one it reviewed.
482
- */
483
- const PREPARATION_REFUSALS = [
484
- 'adyen_test_environment_refused', 'unsupported_checkout', 'merchant_profile_unavailable',
485
- 'merchant_profile_key_changed', 'unknown_merchant_profile', 'unsupported_payment_endpoint',
486
- // A sandbox declaration from a live client or an org without declarations turned on,
487
- // or one the API cannot pay (sandboxMerchants).
488
- 'sandbox_declaration_refused', 'sandbox_declaration_invalid',
489
- ];
490
- /**
491
- * Refusals a Fiserv preparation create answers (4xx) before any preparation exists, surfaced as
492
- * the CheckoutPreparationError reason so the caller can tell them apart: the processor is
493
- * not turned on for this company (`processor_unavailable`), Agentcard has not turned on
494
- * payments to the merchant the key belongs to (`unsupported_checkout`), the merchant's
495
- * published key is no longer the one Agentcard reviewed (`merchant_profile_key_changed`),
496
- * or the key or environment named does not fit the request or the client's test or live
497
- * mode. A 502 `cse_key_unavailable` (the merchant's published key could not be read just
498
- * now; a new prepare() may pass) is surfaced the same way. Only for a Fiserv checkout;
499
- * any other failure, and every other processor's refusal that PREPARATION_REFUSALS does
500
- * not name, stays `preparation_unconfirmed`.
501
- */
502
- const FISERV_PREPARATION_REFUSALS = [
503
- 'processor_unavailable', 'unsupported_checkout', 'merchant_profile_required', 'unknown_merchant_profile',
504
- 'merchant_profile_key_changed', 'fiserv_key_refused', 'sandbox_host_on_live_row', 'live_host_on_sandbox_row',
505
- ];
506
- /** Default backoff for a 502 amount_unverifiable at create: two retries, then give up. */
507
- const UNVERIFIABLE_RETRY_DELAYS_MS = [500, 1500];
508
- const PREPARATION_READ_RETRY_DELAYS_MS = [250, 500];
509
- const TRANSIENT_READ_NETWORK_CODES = new Set([
510
- 'ECONNRESET', 'ECONNREFUSED', 'EPIPE', 'ETIMEDOUT', 'EAI_AGAIN',
511
- 'UND_ERR_SOCKET', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT', 'UND_ERR_BODY_TIMEOUT',
512
- ]);
513
- function transientReadNetworkError(error) {
514
- if (!(error instanceof Error) || error.name === 'AbortError' || error.name === 'TimeoutError' || error instanceof SyntaxError)
515
- return false;
516
- const code = error.cause?.code ?? error.code;
517
- return typeof code === 'string' && TRANSIENT_READ_NETWORK_CODES.has(code);
518
- }
519
- export class VaultClient {
520
- opts;
521
- baseUrl;
522
- fetch;
523
- pollIntervalMs;
524
- unverifiableRetryDelaysMs;
525
- registry;
526
- /**
527
- * The reviewed merchant profiles this client arms: every one this build reviewed,
528
- * in 'observe' until a sync reads that the API enabled it (armServedProfiles).
529
- */
530
- merchantProfiles = armServedProfiles();
531
- /** This client's own sandbox declarations (sandboxMerchants), armed at construction, one per profile. */
532
- declaredProfiles;
533
- preparations = new WeakSet();
534
- usedPreparations = new WeakSet();
535
- constructor(opts) {
536
- this.opts = opts;
537
- this.baseUrl = (opts.baseUrl ?? 'https://api.agentcard.sh').replace(/\/$/, '');
538
- this.fetch = opts.fetchImpl ?? globalThis.fetch;
539
- this.registry = opts.registry ?? BUILTIN_REGISTRY;
540
- this.pollIntervalMs = opts.pollIntervalMs ?? 2000;
541
- this.unverifiableRetryDelaysMs = opts.unverifiableRetryDelaysMs ?? UNVERIFIABLE_RETRY_DELAYS_MS;
542
- const declared = (opts.sandboxMerchants ?? []).map(declareSandboxMerchant);
543
- if (new Set(declared.map((profile) => profile.id)).size !== declared.length) {
544
- throw new TypeError('sandboxMerchants: declare each profile at most once.');
545
- }
546
- this.declaredProfiles = Object.freeze(declared);
547
- }
548
- /** Refresh recognizers from the API so new PSPs work without a redeploy. */
549
- async syncRegistry() {
550
- // Never break checkout over a registry fetch — an auth blip or a bad
551
- // response leaves the built-in recognizers in place.
552
- //
553
- // ?modes= is capability negotiation: the API serves only recognizers in
554
- // the modes this build can finish, so a processor whose flow this SDK
555
- // does not speak is never armed (a pause it cannot complete becomes an
556
- // abort, which would dead-end the checkout). `mode` rides through the
557
- // spread verbatim.
558
- let raw;
559
- // ?capabilities= is the dark-launch half of the same negotiation: a
560
- // processor gated behind a capability is served only when this build lists
561
- // it. This build lists fiserv_card_capture, so it arms Fiserv's card capture
562
- // for a company Agentcard turned Fiserv on for; an older build, and every
563
- // other company's, never sees that recognizer.
564
- const capabilities = SUPPORTED_CAPABILITIES.length ? `&capabilities=${SUPPORTED_CAPABILITIES.join(',')}` : '';
565
- // merchant_profiles=1 asks for the reviewed Adyen merchant profiles too, each
566
- // as a `{ merchant_profile }` entry after the recognizers; an API that
567
- // predates them serves none. Every profile this build reviewed stays armed in
568
- // 'observe' through a failed sync or an answer without profiles, and turns
569
- // 'enabled' only when the API serves it enabled with this build's own rules
570
- // (merchant-hosted.ts armServedProfiles).
571
- try {
572
- raw = await this.get(`/v2/checkout/recognizers?modes=${SUPPORTED_MODES.join(',')}&features=${SUPPORTED_REGISTRY_FEATURES.join(',')}${capabilities}&merchant_profiles=1`);
573
- }
574
- catch {
575
- return;
576
- }
577
- if (!Array.isArray(raw))
578
- return;
579
- const isProfile = (e) => !!e && typeof e === 'object' && 'merchant_profile' in e;
580
- this.registry = raw.filter((e) => !isProfile(e)).map((e) => ({
581
- ...e,
582
- match: new RegExp(e.match, 'i'),
583
- passthroughHeaders: e.passthroughHeaders.map((h) => new RegExp(h, 'i')),
584
- }));
585
- this.merchantProfiles = armServedProfiles(raw.filter(isProfile));
586
- }
587
- /**
588
- * The reviewed Adyen merchant profile whose card endpoint (or a sibling of it,
589
- * the same path under another query) this URL is, with its status on this
590
- * client; null for any other URL. Every profile this build reviewed is armed,
591
- * in 'observe' until a sync reads that the API enabled it. The adapters pause
592
- * these requests and judge them with classifyMerchantRequest. A raw runtime
593
- * that pauses one must never continue a card body there unless authorize()
594
- * paid it.
595
- */
596
- merchantProfileOf(url) {
597
- return merchantProfileFor([...this.declaredProfiles, ...this.merchantProfiles], url);
598
- }
599
- /** The armed profile with this id, or null. A profile id this client declared (sandboxMerchants) names its declaration, in place of the reviewed profile. */
600
- merchantProfile(id) {
601
- return this.declaredProfiles.find((profile) => profile.id === id)
602
- ?? this.merchantProfiles.find((profile) => profile.id === id) ?? null;
603
- }
604
- /**
605
- * Fetch.enable globs for every armed merchant profile's endpoint and its
606
- * siblings, for a raw CDP runtime to arm beside cardUrlPatterns(). Every profile
607
- * this build reviewed is armed from the start, so these are the same before and
608
- * after syncRegistry; a sync changes only a profile's status.
609
- */
610
- merchantProfileUrlPatterns() {
611
- return merchantProfileUrlPatterns([...this.declaredProfiles, ...this.merchantProfiles]);
612
- }
613
- /** True when this request is a card tokenization we can take over. */
614
- isCardRequest(url, method = 'POST') {
615
- return method.toUpperCase() === 'POST' && findRecognizer(url, this.registry) !== null;
616
- }
617
- /**
618
- * What happens to a card request (by URL) whose body carries no card: null
619
- * when it carries one, so it is a card request as usual; 'continue' when the
620
- * recognizer marks the endpoint as one where such requests are routine and
621
- * never ours; 'refuse' otherwise, such as a Stripe confirmation paying with
622
- * a method this checkout never approved. The adapters ask only after their
623
- * holds, so a request that would reuse an approved token is refused there
624
- * first. A body that could not be read is not judged here.
625
- */
626
- withoutCard(url, body) {
627
- if (body == null)
628
- return null;
629
- const rec = findRecognizer(url, this.registry);
630
- return rec ? requestWithoutCard(rec, url, body) : null;
631
- }
632
- /**
633
- * How the card would reach the processor on this request (`token`, `cse`
634
- * or `hosted_form`; absent on the entry means `token`), or null when the
635
- * registry does not recognize it. The adapters read it to decide whether an
636
- * approval outlives the page's own request: a hosted form is a navigation
637
- * and cannot.
638
- */
639
- checkoutModeOf(url, method = 'POST') {
640
- if (method.toUpperCase() !== 'POST')
641
- return null;
642
- const rec = findRecognizer(url, this.registry);
643
- return rec ? rec.mode ?? 'token' : null;
644
- }
645
- /**
646
- * Glob url patterns covering every host the CURRENT registry can send a card
647
- * to — what a raw CDP connection has to hand `Fetch.enable` before any card
648
- * request can be paused. attachToCdp calls this for you.
649
- *
650
- * Call syncRegistry() FIRST: without it these cover only the built-in PSPs,
651
- * and a request the API knows about is never paused at all. Deliberately
652
- * WIDER than the recognizers themselves (a glob cannot express an anchored
653
- * host regex); isCardRequest is the exact check and runs on every request
654
- * these patterns pause.
655
- */
656
- cardUrlPatterns() {
657
- return deriveCardUrlPatterns(this.registry);
658
- }
659
- /** Wait for real device approval before the caller starts native tokenization. */
660
- async prepareCheckout(input) {
661
- input = { ...input };
662
- const fail = (reason, id = null) => new CheckoutPreparationError(id, reason);
663
- if (!validPreparationEnvironment(input.psp, input.environment))
664
- throw fail('unsupported_processor');
665
- // A merchant-hosted preparation pays one reviewed profile's card request: the
666
- // profile must be armed here, enabled, and paid in its own environment.
667
- // A Fiserv checkout names the key Agentcard pinned for its merchant, in that key's own
668
- // environment. The API then names that key's merchant on the approval, and the capture
669
- // this preparation pays must carry an envelope under that key (matchesPreparation).
670
- let profile = null;
671
- let fiservPin = null;
672
- if (input.psp === 'fiserv') {
673
- if (input.merchantProfile === undefined)
674
- throw fail('merchant_profile_required');
675
- fiservPin = fiservKeyPinById(input.merchantProfile);
676
- if (!fiservPin)
677
- throw fail('unknown_merchant_profile');
678
- if (fiservPin.environment !== input.environment)
679
- throw fail('unsupported_processor');
680
- }
681
- else if (input.merchantProfile !== undefined) {
682
- profile = typeof input.merchantProfile === 'string' ? this.merchantProfile(input.merchantProfile) : null;
683
- if (input.psp !== 'adyen')
684
- throw fail('unsupported_processor');
685
- if (!profile)
686
- throw fail('processor_interception_unavailable');
687
- if (profile.status !== 'enabled')
688
- throw fail('unsupported_checkout');
689
- // A declared test endpoint pays on Adyen's TEST platform: a sandbox preparation.
690
- if ((profile.sandboxDeclaration ? 'sandbox' : merchantProfileEnvironment(profile.id)) !== input.environment)
691
- throw fail('unsupported_processor');
692
- }
693
- const declaration = profile?.sandboxDeclaration;
694
- if (!validAmountInput(input.amount) || typeof input.currency !== 'string' || !/^[a-z]{3}$/i.test(input.currency))
695
- throw fail('amount_required');
696
- const origin = new URL(input.merchantOrigin);
697
- if (!(origin.protocol === 'https:' || (origin.protocol === 'http:' && origin.hostname === 'localhost')) || origin.origin !== input.merchantOrigin)
698
- throw fail('merchant_origin_invalid');
699
- if (!input.checkoutKey || !input.user || !input.merchant)
700
- throw fail('checkout_context_required');
701
- // The cardholder approves a payment to the merchant the key pin belongs to, so a Fiserv
702
- // checkout names that merchant as well: the approval, the handle this returns and every
703
- // authorization it pays then name one merchant, never a label the caller chose.
704
- if (fiservPin && input.merchant !== fiservPin.merchant)
705
- throw fail('merchant_profile_mismatch');
706
- const timeoutMs = input.timeoutMs ?? 15 * 60_000;
707
- if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647)
708
- throw fail('timeout_invalid');
709
- const signal = input.signal ? AbortSignal.any([input.signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs);
710
- if (signal.aborted)
711
- throw fail('cancelled');
712
- let id = null;
713
- let ready = false;
714
- try {
715
- // Drain a sent creation even after caller cancellation to retire its ID.
716
- // The separate stop signal prevents dispatch after a slow OAuth exchange.
717
- const created = await this.post('/v2/checkout/preparations', {
718
- user: input.user, merchant: input.merchant, amount: input.amount, currency: input.currency.toLowerCase(),
719
- ...(input.cardId ? { card_id: input.cardId } : {}), psp: input.psp, mode: preparationMode(input.psp),
720
- environment: input.environment, checkout_key: input.checkoutKey, merchant_origin: input.merchantOrigin,
721
- ...(profile ? { merchant_profile: profile.id } : {}),
722
- ...(fiservPin ? { merchant_profile: fiservPin.id } : {}),
723
- ...(declaration ? { sandbox_declaration: { endpoint: declaration.endpoint, client_key: declaration.clientKey, environment: 'test' } } : {}),
724
- }, AbortSignal.timeout(30_000), signal);
725
- if (!created || typeof created.id !== 'string' || !/^cprep_[A-Za-z0-9_-]{1,128}$/.test(created.id))
726
- throw fail('create_unconfirmed');
727
- const preparationId = created.id;
728
- id = preparationId;
729
- try {
730
- Promise.resolve(input.onPreparationCreated?.(preparationId)).catch(() => { });
731
- }
732
- catch { /* observer only */ }
733
- if (signal.aborted)
734
- throw fail('cancelled', id);
735
- if (typeof created.approvalUrl !== 'string')
736
- throw fail('approval_url_missing', id);
737
- try {
738
- Promise.resolve(input.onApprovalUrl?.(created.approvalUrl)).catch(() => { });
739
- }
740
- catch { /* observer only */ }
741
- while (!signal.aborted) {
742
- const state = await this.readPreparation(id, signal);
743
- if (signal.aborted)
744
- throw fail('cancelled', id);
745
- if (state?.id !== id)
746
- throw fail('status_unconfirmed', id);
747
- if (state.status === 'ready') {
748
- const expiry = Date.parse(state.ready_expires_at);
749
- if (!Number.isFinite(expiry) || expiry <= Date.now() || typeof state.card_id !== 'string' || !state.card_id
750
- || state.payment_status !== 'not_started' || state.amount_authority !== 'agent'
751
- // A merchant-hosted preparation names the merchant its reviewed profile names, and
752
- // a declared test endpoint names its host (declareSandboxMerchant), never the caller's label.
753
- // A Fiserv preparation names the merchant its key pin belongs to (input.merchant, checked above), and that pin.
754
- || state.user !== input.user || state.merchant !== (profile ? profile.merchant : input.merchant) || state.merchant_origin !== input.merchantOrigin
755
- || state.merchant_profile !== (profile ? profile.id : fiservPin ? fiservPin.id : undefined)
756
- || !servesDeclaration(state.sandbox_declaration, declaration)
757
- || !Number.isSafeInteger(state.amount) || (typeof input.amount === 'number' && state.amount !== input.amount) || state.currency !== input.currency.toLowerCase()
758
- || state.psp !== input.psp || state.mode !== preparationMode(input.psp) || state.environment !== input.environment
759
- || state.checkout_key !== input.checkoutKey)
760
- throw fail('ready_unconfirmed', id);
761
- const prepared = Object.freeze({
762
- id: preparationId, status: 'ready', psp: input.psp, environment: input.environment, mode: preparationMode(input.psp), expiresAt: state.ready_expires_at,
763
- cardId: state.card_id, user: input.user, merchant: input.merchant, amount: state.amount,
764
- amountDisplay: typeof state.amount_display === 'string' ? state.amount_display : null,
765
- currency: input.currency.toLowerCase(), merchantOrigin: input.merchantOrigin, checkoutKey: input.checkoutKey,
766
- paymentStatus: 'not_started', amountAuthority: 'agent',
767
- ...(profile ? { merchantProfile: profile.id } : {}),
768
- ...(fiservPin ? { merchantProfile: fiservPin.id } : {}),
769
- ...(declaration ? { sandboxDeclaration: declaration } : {}),
770
- });
771
- this.preparations.add(prepared);
772
- ready = true;
773
- return prepared;
774
- }
775
- if (state.status !== 'awaiting_approval')
776
- throw fail(['cancelled', 'expired', 'bound'].includes(state.status) ? state.status : 'status_unconfirmed', id);
777
- await interruptibleSleep(this.pollIntervalMs, signal);
778
- }
779
- throw fail('cancelled', id);
780
- }
781
- catch (error) {
782
- if (error instanceof CheckoutPreparationError)
783
- throw error;
784
- // A refusal the API answered before any preparation existed names its rule: one
785
- // PREPARATION_REFUSALS names for any checkout, and, for a Fiserv checkout only, one
786
- // FISERV_PREPARATION_REFUSALS names or a 502 cse_key_unavailable. Every other
787
- // processor's other refusals stay preparation_unconfirmed, as before.
788
- if (!id && !signal.aborted && error instanceof CheckoutApiError && error.code
789
- && ((error.status >= 400 && error.status < 500 && (PREPARATION_REFUSALS.includes(error.code)
790
- || (input.psp === 'fiserv' && FISERV_PREPARATION_REFUSALS.includes(error.code))))
791
- || (input.psp === 'fiserv' && error.status === 502 && error.code === 'cse_key_unavailable')))
792
- throw fail(error.code);
793
- throw fail(signal.aborted ? 'cancelled' : 'preparation_unconfirmed', id);
794
- }
795
- finally {
796
- if (id && !ready)
797
- await this.cancelPreparation(id).catch(() => { });
798
- }
799
- }
800
- /** Cancel only an unconsumed preparation; a bound request is reconciled separately. */
801
- async cancelPreparation(id) {
802
- if (!/^cprep_[A-Za-z0-9_-]{1,128}$/.test(id))
803
- throw new CheckoutPreparationError(null, 'id_invalid');
804
- const state = await this.post(`/v2/checkout/preparations/${id}/cancel`, {}, AbortSignal.timeout(5_000));
805
- if (state?.id !== id || !['cancelled', 'expired'].includes(state.status))
806
- throw new CheckoutPreparationError(id, 'cancel_unconfirmed');
807
- }
808
- async observePreparation(id, guidance, reason) {
809
- try {
810
- await this.post(`/v2/checkout/preparations/${id}/observe`, {
811
- form_guidance: guidance, ...(reason ? { reason } : {}),
812
- }, AbortSignal.timeout(5_000));
813
- }
814
- catch {
815
- this.opts.onEvent?.({ type: 'telemetry_skipped', detail: 'form_guidance' });
816
- }
817
- }
818
- async reportDuplicateGuard(authorizationId) {
819
- try {
820
- await this.post(`/v2/checkout/authorizations/${authorizationId}/duplicate-guard`, {
821
- guard: 'hosted_form_repeat',
822
- }, AbortSignal.timeout(5_000));
823
- }
824
- catch {
825
- this.opts.onEvent?.({ type: 'telemetry_skipped', detail: 'duplicate_guard' });
826
- }
827
- }
828
- /**
829
- * Hand us a paused tokenization request. We ask the cardholder to approve,
830
- * their device supplies the card and calls the merchant, and you get back the
831
- * response to replay into the browser. Your process never sees a card.
832
- */
833
- async authorize(input) {
834
- const mercadoContext = input.request.mercado_checkout === undefined ? undefined : parseMercadoCheckoutContext(input.request.mercado_checkout);
835
- if (mercadoContext && (!isMercadoTokenRequest(input.request.url, input.request.method)
836
- || input.executionMode === 'autopilot' || input.grantId))
837
- throw new Error('mercado_checkout_context_invalid');
838
- const nativeCheckout = hasStripeCheckoutMarker(input.request);
839
- if (input.stripeCheckoutEnvironment !== undefined && !nativeCheckout)
840
- throw new Error('Native Stripe Checkout environment requires its bound request.');
841
- const checkoutContext = nativeCheckout ? parseStripeCheckoutContext(input.request) : null;
842
- if (nativeCheckout) {
843
- if (!checkoutContext || input.preparation || input.executionMode !== 'autopilot' || !input.grantId
844
- || !Number.isSafeInteger(input.amount) || Number(input.amount) <= 0 || input.currency !== 'usd'
845
- || input.merchantOrigin !== 'https://checkout.stripe.com')
846
- throw new Error('Native Stripe Checkout requires an explicit mode-bound autopilot configuration.');
847
- const phase = classifyStripeCheckoutRequest(input.request, {
848
- environment: input.stripeCheckoutEnvironment, sessionId: checkoutContext.session_id, syntheticPaymentMethodId: checkoutContext.synthetic_payment_method_id,
849
- amountCents: input.amount,
850
- });
851
- if (phase?.phase !== 'final')
852
- throw new Error('Native Stripe Checkout request is not prepared.');
853
- }
854
- const ownedShop = hasOwnedShopMarker(input.request);
855
- const shopOrder = ownedShop ? parseOwnedShopOrder(input.request) : null;
856
- if (ownedShop) {
857
- if (!shopOrder || input.preparation || input.executionMode === 'user_approval' ||
858
- !input.merchantOrigin || (input.amount != null && input.amount !== shopOrder.amount &&
859
- !(typeof input.amount === 'string' && Number(input.amount) * 100 === shopOrder.amount)) ||
860
- (input.currency !== undefined && input.currency.toLowerCase() !== shopOrder.currency))
861
- throw new Error('The shop order cannot use this checkout configuration.');
862
- input = { ...input, amount: shopOrder.amount, currency: shopOrder.currency };
863
- }
864
- if (input.executionMode !== undefined && !['user_approval', 'autopilot'].includes(input.executionMode))
865
- throw new Error('Unsupported checkout execution mode.');
866
- if (input.grantId !== undefined && !/^apg_[A-Za-z0-9_-]{1,128}$/.test(input.grantId))
867
- throw new Error('Invalid autopilot grant ID.');
868
- if (input.preparation && (input.executionMode === 'autopilot' || input.grantId))
869
- throw new Error('A device preparation cannot also select autopilot.');
870
- if (input.merchantOrigin !== undefined && !input.preparation) {
871
- const merchantUrl = new URL(input.merchantOrigin);
872
- if (merchantUrl.protocol !== 'https:' || merchantUrl.origin !== input.merchantOrigin)
873
- throw new Error('merchantOrigin must be an exact HTTPS origin.');
874
- }
875
- const preparation = input.preparation;
876
- if (preparation) {
877
- if (!this.preparations.has(preparation) || this.usedPreparations.has(preparation))
878
- throw new CheckoutPreparationError(preparation.id ?? null, 'already_used_or_foreign');
879
- // Consume locally before any await, including OAuth, and never recycle it.
880
- this.usedPreparations.add(preparation);
881
- if (Date.parse(preparation.expiresAt) <= Date.now())
882
- throw new CheckoutPreparationError(preparation.id, 'expired');
883
- if (input.user !== preparation.user || input.merchant !== preparation.merchant || (typeof input.amount === 'number' && input.amount !== preparation.amount) || (typeof input.amount === 'string' && !validAmountInput(input.amount))
884
- || input.currency?.toLowerCase() !== preparation.currency || input.cardId !== preparation.cardId
885
- || !matchesPreparation(preparation, input.request.url, input.request.method ?? 'POST', input.request.body, input.request.headers))
886
- throw new CheckoutPreparationError(preparation.id, 'checkout_changed');
887
- }
888
- if (input.signal?.aborted)
889
- throw new CheckoutCancelledError();
890
- if (input.merchantSignal?.aborted)
891
- throw new PaymentOutcomeUnknownError(null, 'merchant_request_aborted');
892
- // An armed merchant profile's endpoint (merchant-hosted.ts): the reviewed
893
- // merchant's own server, never a processor host, so no recognizer matches it.
894
- const merchantProfile = nativeCheckout || ownedShop || mercadoContext ? null : this.merchantProfileOf(input.request.url);
895
- // A preparation pays the profile it was made for and nothing else: a
896
- // merchant-hosted one never a processor's request or another profile's (two
897
- // profiles could share an endpoint), and a processor-hosted one never a
898
- // merchant's own endpoint. Checked here, not left to the matcher above, so the
899
- // create below can only ever name the profile the cardholder prepared. A Fiserv
900
- // preparation names a key pin, not a reviewed profile (matchesPreparation binds it).
901
- if (preparation && (((preparation.psp === 'fiserv' ? null : preparation.merchantProfile) ?? null) !== (merchantProfile?.id ?? null)
902
- || (preparation.sandboxDeclaration?.endpoint ?? null) !== (merchantProfile?.sandboxDeclaration?.endpoint ?? null))) {
903
- throw new CheckoutPreparationError(preparation.id, 'checkout_changed');
904
- }
905
- // A profile a later sync holds in 'observe' no longer pays the preparation made for it.
906
- if (preparation && merchantProfile && merchantProfile.status !== 'enabled') {
907
- throw new CheckoutPreparationError(preparation.id, 'checkout_changed');
908
- }
909
- if (merchantProfile)
910
- assertMerchantHostedRequest(input, merchantProfile);
911
- const rec = merchantProfile ? MERCHANT_HOSTED_RECOGNIZER : nativeCheckout ? this.registry.find(entry => entry.psp === 'stripe' && (entry.mode ?? 'token') === 'token')
912
- : findRecognizer(input.request.url, this.registry);
913
- if (!rec)
914
- throw new Error(`not a known tokenization endpoint: ${redactUrl(input.request.url)}`);
915
- // A client-side-encrypted processor is only takeable when its entry says
916
- // the vault produces that ciphertext itself (mode cse); a bare
917
- // clientSideEncrypted entry (an older registry, a hand-built one) still
918
- // refuses here, exactly as before.
919
- const mode = rec.mode ?? 'token';
920
- if (preparation && (rec.psp !== preparation.psp || mode !== preparationMode(preparation.psp)))
921
- throw new CheckoutPreparationError(preparation.id, 'checkout_changed');
922
- // A preparation-required processor may not be taken over on an ordinary
923
- // authorization: the cardholder must have approved a prepare() first. Refuse
924
- // locally, before any create or prompt, when none is active, through the
925
- // same strict rule the API applies (recognizerRequiresPreparation). Fiserv
926
- // sets it; the API serves Fiserv only on a sync that declares its capability,
927
- // and only to a company Agentcard turned Fiserv on for. This build carries
928
- // Fiserv's own rules, so it also requires the preparation for a Fiserv
929
- // request whatever the served entry says (requiresFiservPreparation): a
930
- // registry that dropped the flag never opens an ordinary Fiserv authorization.
931
- if ((recognizerRequiresPreparation(rec) || requiresFiservPreparation(rec.psp, input.request.url)) && !preparation)
932
- throw new PreparationRequiredError(rec.psp);
933
- // The merchant's server picks what it charges Adyen, so the agent's amount is
934
- // what the API holds the body's own amount to: a merchant-hosted payment names one.
935
- if (merchantProfile && input.amount == null) {
936
- throw new Error(`A payment to ${merchantProfile.merchant} names its amount: pass amount and currency, the amount the cardholder approves.`);
937
- }
938
- if (rec.clientSideEncrypted && mode !== 'cse')
939
- throw new CardEncryptedError(rec.psp);
940
- if (!SUPPORTED_MODES.includes(mode))
941
- throw new UnsupportedModeError(mode);
942
- // Say no rather than coerce. isCardRequest only ever pauses a POST, and the
943
- // service stores the replay template with method 'POST' hardcoded — so a
944
- // caller handing in a PUT or GET would get it silently rewritten and the
945
- // cardholder's device would replay something the caller never asked for.
946
- // A direct caller deserves to be told, not surprised.
947
- const method = (input.request.method ?? 'POST').toUpperCase();
948
- if (method !== 'POST') {
949
- throw new Error(`checkout authorization is POST-only; got ${method} for ${redactUrl(input.request.url)}. ` +
950
- 'A non-POST tokenizer needs recognizer support before it can be intercepted.');
951
- }
952
- // The amount is a hint: a pair or nothing. Refuse half a pair here, before
953
- // a network call: the API would too, and a retry loop cannot fix a
954
- // missing field. The processor's own amount is read by Agentcard.
955
- const hasAmount = input.amount != null;
956
- const hasCurrency = typeof input.currency === 'string' && input.currency.length > 0;
957
- if (hasAmount !== hasCurrency) {
958
- throw new Error('amount and currency go together: pass both or neither.');
959
- }
960
- if (hasAmount && !validAmountInput(input.amount)) {
961
- throw new Error('amount is an integer in the currency\'s smallest unit (2306 for $23.06), or a decimal string in normal units ("23.06").');
962
- }
963
- const timeoutMs = input.timeoutMs ?? 15 * 60_000;
964
- if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647)
965
- throw new Error('timeoutMs must be a positive integer no larger than 2147483647.');
966
- const deadline = Date.now() + timeoutMs;
967
- const timeoutSignal = AbortSignal.timeout(timeoutMs);
968
- const operationSignal = input.signal ? AbortSignal.any([input.signal, timeoutSignal]) : timeoutSignal;
969
- // A reviewed profile priced by the merchant's own total: read it from what this browser
970
- // recorded, and refuse before any create when it cannot stand behind this payment.
971
- const priced = merchantProfile && merchantProfilePricedBySource(merchantProfile.id)
972
- ? await this.readMerchantTotal(input, merchantProfile, preparation, operationSignal) : null;
973
- let created;
974
- const payload = {
975
- user: input.user,
976
- merchant: input.merchant,
977
- // snake_case on the wire; camelCase is this SDK's convention.
978
- ...(hasAmount ? { amount: input.amount, currency: input.currency } : {}),
979
- ...(input.pageAmount ? { page_amount: input.pageAmount.amount, page_currency: input.pageAmount.currency } : {}),
980
- ...(Number.isInteger(input.payToInterceptMs) && input.payToInterceptMs >= 0
981
- ? { pay_to_intercept_ms: input.payToInterceptMs }
982
- : {}),
983
- psp: rec.psp,
984
- // The mode this request will be finished in. The API checks it against
985
- // the recognizer and refuses a disagreement before a row exists.
986
- mode,
987
- ...(input.cardId ? { cardId: input.cardId } : {}),
988
- ...(input.executionMode ? { execution_mode: input.executionMode } : {}),
989
- ...(input.grantId ? { grant_id: input.grantId } : {}),
990
- ...(input.merchantOrigin && !preparation ? { merchant_origin: input.merchantOrigin } : {}),
991
- // Only the profile's id: the API takes the key, its environment and where
992
- // the card goes from the profile Agentcard reviewed, never from the caller.
993
- ...(merchantProfile ? { merchant_hosted: { profile: merchantProfile.id, ...(merchantProfile.sandboxDeclaration ? { sandbox_declaration: {
994
- endpoint: merchantProfile.sandboxDeclaration.endpoint, client_key: merchantProfile.sandboxDeclaration.clientKey, environment: 'test'
995
- } } : {}),
996
- ...(priced ? { merchant_total: priced.wire } : {}) } } : {}),
997
- // A prepared checkout already carries the page origin as merchant_origin.
998
- ...(preparation
999
- ? { preparation_id: preparation.id, checkout_key: preparation.checkoutKey, merchant_origin: preparation.merchantOrigin, form_guidance: 'filled' }
1000
- : input.pageOrigin ? { checkout_origin: input.pageOrigin } : {}),
1001
- request: {
1002
- url: input.request.url,
1003
- method: input.request.method,
1004
- headers: { ...pickHeaders(input.request.headers, rec.passthroughHeaders),
1005
- ...(checkoutContext ? { [STRIPE_CHECKOUT_CONTEXT_HEADER]: encodeStripeCheckoutContext(checkoutContext) } : {}) },
1006
- body: input.request.body,
1007
- ...(mercadoContext ? { mercado_checkout: mercadoContext } : {}),
1008
- },
1009
- };
1010
- try {
1011
- created = await this.createAuthorization(payload, input.currency, AbortSignal.any([operationSignal, AbortSignal.timeout(30_000)]), input.merchantSignal);
1012
- }
1013
- catch (error) {
1014
- if (error instanceof PaymentOutcomeUnknownError) {
1015
- if (preparation && !error.authorizationId)
1016
- throw new PaymentOutcomeUnknownError(await this.retireUncertainPreparation(preparation, input.onAuthorizationCreated), error.reason);
1017
- throw error;
1018
- }
1019
- if (preparation && error instanceof CheckoutApiError && error.code === 'preparation_bound') {
1020
- const id = typeof error.details.authorization_id === 'string' && /^cauth_[A-Za-z0-9_-]+$/.test(error.details.authorization_id) ? error.details.authorization_id : null;
1021
- if (id) {
1022
- try {
1023
- Promise.resolve(input.onAuthorizationCreated?.(id)).catch(() => { });
1024
- }
1025
- catch { /* observer only */ }
1026
- }
1027
- throw new PaymentOutcomeUnknownError(id, 'preparation_already_bound');
1028
- }
1029
- // A missing answer or generic 5xx can hide a committed row and a delivered approval link.
1030
- // Only the documented pre-create read-back errors prove it is safe to retry.
1031
- const safeReadFailure = error instanceof CheckoutApiError
1032
- && error.status === 502 && (error.code === 'amount_unverifiable' || error.code === 'cse_key_unavailable');
1033
- if ((error instanceof CheckoutApiError && error.status >= 500 && !safeReadFailure)
1034
- || (!(error instanceof CheckoutApiError) && !(error instanceof ApprovalDeclinedError))) {
1035
- throw new PaymentOutcomeUnknownError(preparation ? await this.retireUncertainPreparation(preparation, input.onAuthorizationCreated) : null, 'authorization_create_unanswered');
1036
- }
1037
- throw error;
1038
- }
1039
- if (!created || typeof created.id !== 'string' || !created.id)
1040
- throw new PaymentOutcomeUnknownError(preparation ? await this.retireUncertainPreparation(preparation, input.onAuthorizationCreated) : null, 'authorization_create_malformed');
1041
- const authorizationId = created.id;
1042
- // A prepared Mercado token already has the person's approval. Poll its
1043
- // result promptly while the native SDK waits: at the default interval,
1044
- // 10 seconds has at most 20 reads instead of 5 (15 additional reads).
1045
- // Then restore the caller's interval. Human approval waiting, cancellation,
1046
- // merchant deadlines, and the single processor request remain unchanged.
1047
- const preparedMercadoPollUntil = preparation?.psp === 'mercado_pago' ? Date.now() + 10_000 : 0;
1048
- let execution = {};
1049
- let approvalDelivered = false;
1050
- const deliverApproval = (state) => {
1051
- if (ownedShop || nativeCheckout || preparation || approvalDelivered || execution.executionMode === 'autopilot')
1052
- return;
1053
- const url = state.approvalUrl ?? state.approval_url;
1054
- if (typeof url !== 'string' || !url)
1055
- return;
1056
- approvalDelivered = true;
1057
- try {
1058
- Promise.resolve(input.onApprovalUrl?.(url)).catch(() => { });
1059
- }
1060
- catch { /* observer only */ }
1061
- };
1062
- let failed = false;
1063
- let actionRequired = false;
1064
- const checkAutopilotAction = (state) => {
1065
- if (state.autopilot_status !== 'action_required')
1066
- return;
1067
- // The authenticated API reports only a verified completion category.
1068
- // Retain this authorization without returning its processor payload or
1069
- // inferring whether the required action is 3DS, a redirect, or another step.
1070
- if (state.id !== authorizationId || state.status !== 'awaiting_approval' || state.mode !== 'token'
1071
- || state.execution_mode !== 'autopilot' || execution.executionMode !== 'autopilot'
1072
- || state.grant_id !== execution.grantId || (input.grantId && state.grant_id !== input.grantId)) {
1073
- throw new PaymentOutcomeUnknownError(authorizationId, 'autopilot_action_unconfirmed');
1074
- }
1075
- actionRequired = true;
1076
- throw new PaymentOutcomeUnknownError(authorizationId, 'autopilot_action_required');
1077
- };
1078
- const stopSignal = input.merchantSignal
1079
- ? AbortSignal.any([input.merchantSignal, ...(input.signal ? [input.signal] : [])]) : input.signal;
1080
- try {
1081
- try {
1082
- Promise.resolve(input.onAuthorizationCreated?.(authorizationId)).catch(() => { });
1083
- }
1084
- catch { /* observer only */ }
1085
- if (input.merchantSignal?.aborted)
1086
- throw new PaymentOutcomeUnknownError(authorizationId, 'merchant_request_aborted');
1087
- execution = executionMetadata(created, authorizationId);
1088
- checkAutopilotAction(created);
1089
- deliverApproval(created);
1090
- while (Date.now() < deadline) {
1091
- if (stopSignal?.aborted)
1092
- throw new PaymentOutcomeUnknownError(authorizationId, input.merchantSignal?.aborted ? 'merchant_request_aborted' : 'local_cancel');
1093
- const pollInterval = Date.now() < preparedMercadoPollUntil ? Math.min(this.pollIntervalMs, 500) : this.pollIntervalMs;
1094
- await interruptibleSleep(Math.min(pollInterval, Math.max(0, deadline - Date.now())), stopSignal);
1095
- if (stopSignal?.aborted)
1096
- throw new PaymentOutcomeUnknownError(authorizationId, input.merchantSignal?.aborted ? 'merchant_request_aborted' : 'local_cancel');
1097
- let s;
1098
- const deadlineSignal = AbortSignal.timeout(Math.max(1, deadline - Date.now()));
1099
- const pollSignal = stopSignal ? AbortSignal.any([stopSignal, deadlineSignal]) : deadlineSignal;
1100
- try {
1101
- s = await this.get(`/v2/checkout/authorizations/${authorizationId}`, pollSignal);
1102
- }
1103
- catch {
1104
- throw new PaymentOutcomeUnknownError(authorizationId, input.merchantSignal?.aborted ? 'merchant_request_aborted' : 'authorization_poll_failed');
1105
- }
1106
- if (input.merchantSignal?.aborted)
1107
- throw new PaymentOutcomeUnknownError(authorizationId, 'merchant_request_aborted');
1108
- if (!s || typeof s !== 'object' || Array.isArray(s) || typeof s.status !== 'string') {
1109
- throw new PaymentOutcomeUnknownError(authorizationId, 'authorization_status_malformed');
1110
- }
1111
- execution = executionMetadata(s, authorizationId, execution);
1112
- checkAutopilotAction(s);
1113
- if (nativeCheckout && (s.status === 'submitted_on_device' ||
1114
- (s.status === 'approved' && (s.mode !== 'token' || execution.executionMode !== 'autopilot'
1115
- || execution.grantId !== input.grantId))))
1116
- throw new PaymentOutcomeUnknownError(authorizationId, 'checkout_receipt_unconfirmed');
1117
- if (shopOrder && (s.status === 'submitted_on_device' ||
1118
- (s.status === 'approved' && (s.mode !== 'token' || execution.executionMode !== 'autopilot'))))
1119
- throw new PaymentOutcomeUnknownError(authorizationId, 'shop_receipt_unconfirmed');
1120
- deliverApproval(s);
1121
- const amountAuthority = typeof s.amount_authority === 'string' && AMOUNT_AUTHORITIES.includes(s.amount_authority)
1122
- ? { amountAuthority: s.amount_authority }
1123
- : {};
1124
- // A merchant-hosted create finishes only as a cse approval: the API's
1125
- // database forces these rows to cse. A finished read in any other mode
1126
- // (token, a missing mode, a hosted form) is not one to act on, and never
1127
- // reaches the code that would answer the merchant's paused request with it.
1128
- if (merchantProfile && (s.status === 'approved' || s.status === 'submitted_on_device') && s.mode !== 'cse') {
1129
- throw new PaymentOutcomeUnknownError(authorizationId, 'merchant_hosted_mode_invalid');
1130
- }
1131
- if (s.status === 'submitted_on_device') {
1132
- // The device attested that the processor's form left it; the stamp
1133
- // is the whole fact and it is NOT an approval (see HostedFormReplay).
1134
- // Only a hosted_form row may carry this status; a stamp without its
1135
- // time is not one this SDK can act on. The device may already have paid,
1136
- // so the adapter holds the attempt until the merchant is reconciled.
1137
- const mode = typeof s.mode === 'string' ? s.mode : 'token';
1138
- if (mode !== 'hosted_form')
1139
- throw new PaymentOutcomeUnknownError(authorizationId, 'submission_mode_mismatch');
1140
- if (typeof s.submitted_at !== 'string' || !s.submitted_at) {
1141
- throw new PaymentOutcomeUnknownError(authorizationId, 'submission_timestamp_missing');
1142
- }
1143
- if (execution.executionMode === 'autopilot')
1144
- throw new PaymentOutcomeUnknownError(authorizationId, 'autopilot_submission_mode_invalid');
1145
- return { mode: 'hosted_form', kind: 'submitted_on_device', outcome: 'unverified', authorizationId, submittedAt: s.submitted_at, ...amountAuthority, ...execution };
1146
- }
1147
- if (s.status === 'approved') {
1148
- const approvedMode = typeof s.mode === 'string' ? s.mode : 'token';
1149
- if (approvedMode === 'hosted_form') {
1150
- // Never: the API finishes a hosted form as submitted_on_device, and
1151
- // its database refuses `approved` on that mode. An answer that says
1152
- // otherwise is not one to act on as a payment.
1153
- throw new PaymentOutcomeUnknownError(authorizationId, 'hosted_form_approval_malformed');
1154
- }
1155
- if (approvedMode === 'cse') {
1156
- if (execution.executionMode === 'autopilot')
1157
- throw new PaymentOutcomeUnknownError(authorizationId, 'autopilot_submission_mode_invalid');
1158
- // A merchant-hosted create reads back only a merchant-hosted approval,
1159
- // and a processor-hosted one never does.
1160
- if (merchantProfile || (s.substitutions && typeof s.substitutions === 'object' && 'kind' in s.substitutions)) {
1161
- return { ...merchantHostedApproval(s, authorizationId, merchantProfile), ...(priced ? { merchantTotal: priced.approved } : {}),
1162
- ...amountAuthority, ...execution };
1163
- }
1164
- // The API serves a Fiserv envelope only briefly after the approval (it carries
1165
- // no timestamp, so whoever holds it could resubmit it). A read after that
1166
- // window says so instead of serving it; the capture was never continued here.
1167
- if (s.substitutions_expired === true)
1168
- throw new PaymentOutcomeUnknownError(authorizationId, 'cse_substitutions_expired');
1169
- const sub = s.substitutions;
1170
- const fieldsOk = sub && typeof sub === 'object' && sub.encoding === 'json' && typeof sub.at === 'string' && sub.at
1171
- && sub.fields && typeof sub.fields === 'object' && !Array.isArray(sub.fields)
1172
- && Object.values(sub.fields).every((v) => typeof v === 'string' && v.length > 0);
1173
- if (!fieldsOk)
1174
- throw new PaymentOutcomeUnknownError(authorizationId, 'cse_substitutions_malformed');
1175
- // A Fiserv approval carries exactly its envelope's four members at
1176
- // source.encryptionData and removes nothing (fiserv.ts); any other shape is not one to act on.
1177
- if (rec.psp === 'fiserv' && !isFiservSubstitution(sub))
1178
- throw new PaymentOutcomeUnknownError(authorizationId, 'cse_substitutions_malformed');
1179
- // `remove`: sibling keys the API says to drop with the swap (Adyen's
1180
- // `brand`, stamped by adyen-web from the agent's dummy digits). Absent
1181
- // on an older API; anything but a list of names is refused, since a
1182
- // half-understood instruction would continue a body Adyen refuses.
1183
- const removeRaw = sub.remove;
1184
- if (removeRaw !== undefined && !(Array.isArray(removeRaw) && removeRaw.every((k) => typeof k === 'string' && k.length > 0))) {
1185
- throw new PaymentOutcomeUnknownError(authorizationId, 'cse_remove_malformed');
1186
- }
1187
- return {
1188
- mode: 'cse',
1189
- authorizationId,
1190
- substitutions: {
1191
- encoding: 'json',
1192
- at: sub.at,
1193
- fields: { ...sub.fields },
1194
- ...(removeRaw ? { remove: [...removeRaw] } : {}),
1195
- },
1196
- ...amountAuthority,
1197
- ...execution,
1198
- };
1199
- }
1200
- if (approvedMode !== 'token')
1201
- throw new PaymentOutcomeUnknownError(authorizationId, 'approved_mode_unsupported');
1202
- const response = s.response;
1203
- if (!response || !Number.isInteger(response.status) || response.status < 100 || response.status > 599
1204
- || typeof response.body !== 'string' || !response.headers || typeof response.headers !== 'object' || Array.isArray(response.headers)) {
1205
- throw new PaymentOutcomeUnknownError(authorizationId, 'approved_response_malformed');
1206
- }
1207
- if (shopOrder) {
1208
- let receipt;
1209
- try {
1210
- receipt = parseOwnedShopReceipt(JSON.parse(response.body), shopOrder);
1211
- }
1212
- catch { /* No raw error or token replay. */ }
1213
- if (execution.executionMode !== 'autopilot' || response.status !== 200 || !receipt)
1214
- throw new PaymentOutcomeUnknownError(authorizationId, 'shop_receipt_unconfirmed');
1215
- // Only canonical receipt fields and our content type enter the page.
1216
- response.body = JSON.stringify(receipt);
1217
- response.headers = { 'content-type': 'application/json' };
1218
- }
1219
- if (checkoutContext) {
1220
- let receipt;
1221
- try {
1222
- receipt = parseStripeCheckoutResponse(JSON.parse(response.body), { environment: input.stripeCheckoutEnvironment, sessionId: checkoutContext.session_id });
1223
- }
1224
- catch { /* Never return an unvalidated processor body to the page. */ }
1225
- if (response.status !== 200 || !receipt || s.amount_verified !== true
1226
- || s.charged_amount !== input.amount || s.charged_currency !== 'usd' || s.charged_kind !== 'captured')
1227
- throw new PaymentOutcomeUnknownError(authorizationId, 'checkout_receipt_unconfirmed');
1228
- response.body = JSON.stringify(receipt);
1229
- response.headers = { 'content-type': 'application/json' };
1230
- }
1231
- let processorContext;
1232
- if (mercadoContext) {
1233
- try {
1234
- processorContext = await validateMercadoProcessorContext(s.processor_context, mercadoContext, input.request.url, response.body);
1235
- }
1236
- catch {
1237
- throw new PaymentOutcomeUnknownError(authorizationId, 'mercado_checkout_metadata_invalid');
1238
- }
1239
- }
1240
- else if (s.processor_context !== undefined)
1241
- throw new PaymentOutcomeUnknownError(authorizationId, 'unexpected_processor_context');
1242
- return {
1243
- mode: 'token',
1244
- authorizationId,
1245
- ...(shopOrder ? { shopOrderId: shopOrder.order_id } : {}),
1246
- ...(checkoutContext ? { checkoutSessionId: checkoutContext.session_id } : {}),
1247
- ...response,
1248
- ...(processorContext ? { processorContext } : {}),
1249
- amountVerified: typeof s.amount_verified === 'boolean' ? s.amount_verified : null,
1250
- chargedAmount: typeof s.charged_amount === 'number' ? s.charged_amount : null,
1251
- chargedCurrency: typeof s.charged_currency === 'string' ? s.charged_currency : null,
1252
- chargedKind: s.charged_kind === 'captured' || s.charged_kind === 'authorized' || s.charged_kind === 'none' ? s.charged_kind : null,
1253
- ...amountAuthority,
1254
- ...execution,
1255
- };
1256
- }
1257
- if (s.status === 'declined') {
1258
- // The pre-replay checks declined it: typed, with the numbers, so the
1259
- // caller can say what happened rather than "the user said no".
1260
- if (s.reason === 'amount_mismatch') {
1261
- throw new AmountMismatchError(String(created.id), Number(s.expected_cents), Number(s.actual_cents), String(s.currency ?? input.currency ?? ''), s.actual_currency != null ? String(s.actual_currency) : undefined, 'pre_replay');
1262
- }
1263
- if (s.reason === 'intent_not_confirmable')
1264
- throw new IntentNotConfirmableError(String(created.id));
1265
- if (typeof s.reason === 'string' && ADYEN_TEST_PLATFORM_REFUSALS.includes(s.reason)) {
1266
- throw new AdyenTestPlatformRefusedError(String(created.id), s.reason, 'pre_replay');
1267
- }
1268
- if (typeof s.reason === 'string' && PRESET_REFUSAL_REASONS.has(s.reason)) {
1269
- throw new PresetRefusedError(String(created.id), s.reason, typeof s.message === 'string' ? s.message : null, presetOf(s.preset), typeof s.rule === 'string' ? s.rule : null, 'pre_replay', attachmentOf(s.attachment), refusalsOf(s.refusals));
1270
- }
1271
- if (s.reason === 'processor_refused') {
1272
- throw new ProcessorRefusedError(String(created.id), typeof s.psp_error_code === 'string' ? s.psp_error_code : null, s.psp === 'razorpay' ? parseProcessorError(s.processor_error) : null);
1273
- }
1274
- throw new ApprovalDeclinedError(s.reason ?? 'no reason given');
1275
- }
1276
- if (s.status === 'expired') {
1277
- if (s.replay_attempted === false)
1278
- throw new ApprovalTimeoutError(timeoutMs);
1279
- throw new PaymentOutcomeUnknownError(authorizationId, 'expired_after_possible_replay');
1280
- }
1281
- if (s.status !== 'awaiting_approval')
1282
- throw new PaymentOutcomeUnknownError(authorizationId, 'authorization_status_unrecognized');
1283
- }
1284
- // A local deadline is not server-side expiry; the person may still use the approval link.
1285
- throw new PaymentOutcomeUnknownError(authorizationId, 'local_approval_timeout');
1286
- }
1287
- catch (error) {
1288
- failed = true;
1289
- if (input.merchantSignal?.aborted)
1290
- throw new PaymentOutcomeUnknownError(authorizationId, 'merchant_request_aborted');
1291
- throw error;
1292
- }
1293
- finally {
1294
- // An attested action-required result retains its claim and reservation.
1295
- // Reporting that state must not request cancellation of the same attempt.
1296
- if (!actionRequired && (input.merchantSignal?.aborted || ((preparation || nativeCheckout) && failed))) {
1297
- // Drain a create acknowledgement even after the merchant aborts so its
1298
- // known ID can be retired. An unacknowledged create remains unknown.
1299
- // A started/finalized replay or failed cleanup never becomes a claimed
1300
- // cancellation, and this best-effort cleanup never retries a payment.
1301
- await this.cancelAuthorization(authorizationId).catch(() => { });
1302
- }
1303
- }
1304
- }
1305
- /** A lost bind acknowledgement must never resume the request. Recover metadata only for safe cleanup. */
1306
- async retireUncertainPreparation(preparation, onCreated) {
1307
- // Race cancellation atomically against a create still arriving at the API.
1308
- // A bound preparation refuses cancellation; resolve its ID exactly once below.
1309
- await this.cancelPreparation(preparation.id).catch(() => { });
1310
- let state;
1311
- try {
1312
- state = await this.get(`/v2/checkout/preparations/${preparation.id}`, AbortSignal.timeout(3_000));
1313
- }
1314
- catch {
1315
- return null;
1316
- }
1317
- if (state?.id !== preparation.id || state.status !== 'bound' || typeof state.authorization_id !== 'string'
1318
- || !/^cauth_[A-Za-z0-9_-]{1,128}$/.test(state.authorization_id))
1319
- return null;
1320
- const id = state.authorization_id;
1321
- try {
1322
- Promise.resolve(onCreated?.(id)).catch(() => { });
1323
- }
1324
- catch { /* observer only */ }
1325
- await this.cancelAuthorization(id).catch(() => { });
1326
- return id;
1327
- }
1328
- /**
1329
- * Retire an authorization the runtime is done with. A 409 or missing
1330
- * response remains unknown.
1331
- *
1332
- * `merchant_request_aborted` (the default): the merchant request is gone
1333
- * and the row must still be awaiting the person with no replay started; the
1334
- * acknowledgement carries `processor_request_started: false`.
1335
- *
1336
- * `merchant_never_retried`: the page abandoned its request while the person
1337
- * decided, the adapter kept the approval for the page's retry, and none
1338
- * came. The row may already be `approved` (a token or ciphertext minted on
1339
- * the device that this runtime handed to no request), so
1340
- * `processor_request_started` says whether the processor was asked; the API
1341
- * stamps this reason only where no charge can have been made and answers
1342
- * 409 otherwise. The acknowledgement echoes the reason the row actually
1343
- * carries: a row retired earlier under the other runtime reason answers
1344
- * with that one.
1345
- */
1346
- async cancelAuthorization(authorizationId, reason = 'merchant_request_aborted') {
1347
- if (!/^cauth_[A-Za-z0-9_-]{1,128}$/.test(authorizationId))
1348
- throw new Error('Invalid authorization ID.');
1349
- if (!RUNTIME_CANCEL_REASONS.includes(reason))
1350
- throw new Error('Unsupported cancellation reason.');
1351
- const result = await this.post(`/v2/checkout/authorizations/${authorizationId}/cancel`, reason === 'merchant_request_aborted' ? {} : { reason }, AbortSignal.timeout(5_000));
1352
- const recorded = result?.reason;
1353
- if (result?.id !== authorizationId || result.status !== 'declined' || result.cancelled !== true
1354
- || typeof recorded !== 'string' || !RUNTIME_CANCEL_REASONS.includes(recorded)
1355
- || typeof result.processor_request_started !== 'boolean'
1356
- // The plain cancellation is only ever confirmed before the card moved.
1357
- || (recorded === 'merchant_request_aborted' && result.processor_request_started !== false)) {
1358
- throw new PaymentOutcomeUnknownError(authorizationId, 'authorization_cancel_unconfirmed');
1359
- }
1360
- return { id: authorizationId, status: 'declined', reason: recorded,
1361
- cancelled: true, processor_request_started: result.processor_request_started };
1362
- }
1363
- /**
1364
- * Ask whether the page may pay with the Stripe card token an approval
1365
- * produced: a PaymentIntent confirm that carries no card and pays with
1366
- * exactly the approved payment method, card token, confirmation token or
1367
- * source. The API reads the payment from Stripe and answers only for the
1368
- * approved amount and currency on the same Stripe account; any other answer
1369
- * rejects with a CheckoutApiError whose code says why (for example
1370
- * `continuation_not_bound`, `amount_mismatch`). Nothing is charged here:
1371
- * the adapters continue the page's own request once this resolves.
1372
- */
1373
- async checkStripeContinuation(authorizationId, request) {
1374
- if (!/^cauth_[A-Za-z0-9_-]{1,128}$/.test(authorizationId))
1375
- throw new Error('Invalid authorization ID.');
1376
- const rec = findRecognizer(request.url, this.registry);
1377
- if (rec?.psp !== 'stripe')
1378
- throw new Error('Only a Stripe request can continue an approved Stripe token.');
1379
- const result = await this.post(`/v2/checkout/authorizations/${authorizationId}/continuations`, {
1380
- request: { url: request.url, method: request.method, headers: pickHeaders(request.headers, rec.passthroughHeaders), body: request.body },
1381
- }, AbortSignal.timeout(15_000));
1382
- const pi = result?.payment_intent;
1383
- if (result?.object !== 'checkout_continuation' || result.continuation !== 'allowed' || result.authorization !== authorizationId
1384
- || !pi || typeof pi.id !== 'string' || !Number.isSafeInteger(pi.amount) || typeof pi.currency !== 'string') {
1385
- throw new Error('The continuation answer was not recognized.');
1386
- }
1387
- return { paymentIntentId: pi.id, amount: pi.amount, currency: pi.currency };
1388
- }
1389
- /**
1390
- * After a merchant-hosted payment priced by the merchant's own total: what the merchant's
1391
- * confirmation says it charged (merchant-total.ts merchantChargeReport builds `report`
1392
- * from the responses this browser recorded after the card request). The API compares it
1393
- * with the approved amount and alerts Agentcard once when the merchant charged more, or
1394
- * in another currency. The adapters send it on their own; the verdict is 'equal',
1395
- * 'lower', 'higher', 'currency_mismatch', or 'unread' when no response read.
1396
- */
1397
- async reportMerchantCharge(authorizationId, report) {
1398
- if (!/^cauth_[A-Za-z0-9_-]{1,128}$/.test(authorizationId))
1399
- throw new Error('Invalid authorization ID.');
1400
- const result = await this.post(`/v2/checkout/authorizations/${authorizationId}/merchant_charge`, report, AbortSignal.timeout(10_000));
1401
- const verdict = result?.verdict;
1402
- if (result?.authorization_id !== authorizationId || !['equal', 'lower', 'higher', 'currency_mismatch', 'unread'].includes(verdict)) {
1403
- throw new Error('The merchant charge report was not acknowledged.');
1404
- }
1405
- return { verdict, alerted: result.alerted === true };
1406
- }
1407
- /**
1408
- * The merchant's own total for a paused card request whose reviewed profile is priced by
1409
- * one: the exchanges the adapter recorded (once every request that can move the total has
1410
- * answered), cut to what the profile's rules read, and the same reading the API makes.
1411
- * Refused with a MerchantTotalError before any create when nothing recorded them, one is
1412
- * still unanswered, or they do not confirm one total for this order.
1413
- */
1414
- async readMerchantTotal(input, profile, preparation, signal) {
1415
- const refuse = (code, reasonCode, reason) => new MerchantTotalError(null, code, 'sdk', reasonCode, reason);
1416
- const capture = input.merchantTotal;
1417
- if (!capture || typeof capture.exchanges !== 'function' || typeof capture.pausedAt !== 'number' || !Number.isFinite(capture.pausedAt)) {
1418
- throw refuse('merchant_total_required', null, `${profile.merchant}'s own total is read from its checkout's responses, and this runtime recorded none`);
1419
- }
1420
- const checkoutOrigin = preparation?.merchantOrigin ?? input.pageOrigin;
1421
- const card = {
1422
- url: input.request.url, method: (input.request.method ?? 'POST').toUpperCase(), body: input.request.body, pausedAt: capture.pausedAt,
1423
- ...(checkoutOrigin ? { checkoutOrigin } : {}), ...(capture.page ? { page: capture.page } : {}),
1424
- ...(profile.sandboxDeclaration ? { declaredEndpoint: profile.sandboxDeclaration.endpoint } : {}),
1425
- };
1426
- let seen;
1427
- try {
1428
- seen = await capture.exchanges(profile.id, 'source', card, signal);
1429
- }
1430
- catch (error) {
1431
- if (error instanceof MerchantTotalWait)
1432
- throw refuse('merchant_total_refused', 'stale', `${profile.merchant}'s total is not known: ${error.message}`);
1433
- throw error;
1434
- }
1435
- const sent = merchantAmountExchanges(profile.id, 'source', { card, responses: seen });
1436
- if (!sent.ok)
1437
- throw refuse('merchant_total_refused', sent.code, `Refusing to pay ${profile.merchant}: ${sent.reason}`);
1438
- const read = merchantAmountFromResponses(profile.id, { card, responses: sent.responses });
1439
- if (!read.ok)
1440
- throw refuse('merchant_total_refused', read.code, `Refusing to pay ${profile.merchant}: ${read.reason}`);
1441
- // The agent's number is held to the merchant's before anyone is asked. An integer amount is
1442
- // compared here; a decimal string is left to the API, which reads it in the currency's units.
1443
- if (typeof input.amount === 'number' && typeof input.currency === 'string'
1444
- && (input.amount !== read.amount.amount || input.currency.toLowerCase() !== read.amount.currency.toLowerCase())) {
1445
- // The same typed refusal the API's 409 amount_mismatch becomes, naming the merchant's total.
1446
- throw new AmountMismatchError(null, input.amount, read.amount.amount, input.currency.toLowerCase(), read.amount.currency, 'create', 'merchant_total');
1447
- }
1448
- return { wire: merchantTotalWire(capture.pausedAt, capture.page, sent.responses), approved: { card, amount: read.amount } };
1449
- }
1450
- /**
1451
- * POST the create, with two typed twists: a 502 `amount_unverifiable`
1452
- * (Stripe did not answer the read-back) is retried on a short backoff
1453
- * instead of being left to the page's own retry loop, and a 409
1454
- * `amount_mismatch` becomes an AmountMismatchError at stage 'create' so the
1455
- * adapters treat it as an answered request, not a dead page.
1456
- */
1457
- async createAuthorization(payload, currency, signal, stopRetries) {
1458
- for (let attempt = 0;; attempt++) {
1459
- if (stopRetries?.aborted)
1460
- throw new PaymentOutcomeUnknownError(null, 'merchant_request_aborted');
1461
- try {
1462
- return await this.post('/v2/checkout/authorizations', payload, signal, stopRetries);
1463
- }
1464
- catch (err) {
1465
- if (err instanceof CheckoutApiError && err.code && err.status === 403 && presetOf(err.details.preset)) {
1466
- // A company preset refused this purchase before a row existed.
1467
- const d = err.details;
1468
- throw new PresetRefusedError(null, err.code, typeof d.message === 'string' ? d.message : null, presetOf(d.preset), typeof d.rule === 'string' ? d.rule : null, 'create', attachmentOf(d.attachment), refusalsOf(d.refusals));
1469
- }
1470
- if (err instanceof CheckoutApiError && err.status === 400 && err.code === 'adyen_test_environment_refused') {
1471
- // A live checkout named Adyen's test host or a test_ key: refused before anyone was asked.
1472
- throw new AdyenTestPlatformRefusedError(null, 'adyen_test_environment_refused', 'create');
1473
- }
1474
- if (err instanceof CheckoutApiError && err.code === 'amount_mismatch') {
1475
- const d = err.details;
1476
- throw new AmountMismatchError(null, Number(d.expected_cents), Number(d.actual_cents), String(d.currency ?? currency ?? ''), d.actual_currency != null ? String(d.actual_currency) : undefined, 'create',
1477
- // A merchant-hosted refusal names the merchant amount it held the agent's to:
1478
- // its card request's own ('body') or its checkout total (an amount source's id).
1479
- typeof d.amount_source === 'string' ? (d.amount_source === 'body' ? 'merchant_request' : 'merchant_total') : 'processor');
1480
- }
1481
- if (err instanceof CheckoutApiError && err.status === 409 && (err.code === 'merchant_total_required' || err.code === 'merchant_total_refused')) {
1482
- // The API could not confirm the merchant's own total for this order: no row exists.
1483
- const d = err.details;
1484
- throw new MerchantTotalError(null, err.code, 'create', typeof d.reason_code === 'string' ? d.reason_code : null, typeof d.reason === 'string' ? `Agentcard could not confirm the merchant's own total: ${d.reason}` : err.message);
1485
- }
1486
- // Two 502s the API asks to be retried: Stripe did not answer the
1487
- // amount read-back, or Adyen did not answer the public-key fetch.
1488
- const retryable = !payload.preparation_id && err instanceof CheckoutApiError && err.status === 502
1489
- && (err.code === 'amount_unverifiable' || err.code === 'cse_key_unavailable');
1490
- if (!retryable || attempt >= this.unverifiableRetryDelaysMs.length)
1491
- throw err;
1492
- await interruptibleSleep(this.unverifiableRetryDelaysMs[attempt], stopRetries ? AbortSignal.any([stopRetries, ...(signal ? [signal] : [])]) : signal);
1493
- if (signal?.aborted)
1494
- throw signal.reason;
1495
- }
1496
- }
1497
- }
1498
- // --- auth: client_credentials, cached until just before it expires --------
1499
- token = null;
1500
- inflight = null;
1501
- /**
1502
- * Exchange client credentials for an access token, reusing the cached one
1503
- * until it is nearly expired. Concurrent callers share a single in-flight
1504
- * exchange rather than each minting their own token.
1505
- */
1506
- async accessToken(force = false) {
1507
- if (!force && this.token && Date.now() < this.token.expiresAt)
1508
- return this.token.value;
1509
- if (!force && this.inflight)
1510
- return this.inflight;
1511
- this.inflight = (async () => {
1512
- const body = new URLSearchParams({
1513
- grant_type: 'client_credentials',
1514
- client_id: this.opts.clientId,
1515
- client_secret: this.opts.clientSecret,
1516
- });
1517
- const r = await this.fetch(`${this.baseUrl}/api/v1/oauth/token`, {
1518
- method: 'POST',
1519
- headers: { 'content-type': 'application/x-www-form-urlencoded' },
1520
- body: body.toString(),
1521
- signal: AbortSignal.timeout(30_000),
1522
- });
1523
- if (!r.ok) {
1524
- // RFC 6749 error shape, which real OAuth clients expect verbatim.
1525
- const d = await r.json().catch(() => ({}));
1526
- throw new Error(`agentcard auth failed: ${d.error ?? r.status} ${d.error_description ?? ''}`.trim());
1527
- }
1528
- const d = (await r.json());
1529
- // Renew a minute early so a token never expires mid-checkout.
1530
- const ttl = Math.max(60, (d.expires_in ?? 3600) - 60);
1531
- this.token = { value: d.access_token, expiresAt: Date.now() + ttl * 1000 };
1532
- return this.token.value;
1533
- })().finally(() => { this.inflight = null; });
1534
- return this.inflight;
1535
- }
1536
- /**
1537
- * A preparation status read may retry two known connection failures, after
1538
- * 250ms and 500ms, while keeping its original approval deadline and signal.
1539
- * Only fetch and a successful response's body read belong to that retry:
1540
- * OAuth, HTTP errors, invalid JSON and every mutation remain outside it.
1541
- * The existing one-time 401 refresh carries the remaining read budget.
1542
- */
1543
- async call(path, init = {}, retried = false, stopNewRequests, preparationReadAttempt) {
1544
- const signal = init.signal ?? AbortSignal.timeout(30_000);
1545
- const token = await withSignal(this.accessToken(), signal);
1546
- // Auth can outlive the merchant request. Stop a create that has not left
1547
- // yet, while preserving the response of one already sent for cleanup.
1548
- if (stopNewRequests?.aborted)
1549
- throw new PaymentOutcomeUnknownError(null, 'merchant_request_aborted');
1550
- for (;;) {
1551
- signal.throwIfAborted();
1552
- let r;
1553
- try {
1554
- r = await withSignal(this.fetch(`${this.baseUrl}${path}`, {
1555
- ...init,
1556
- signal,
1557
- headers: { ...(init.headers ?? {}), authorization: `Bearer ${token}`, 'content-type': 'application/json' },
1558
- }), signal);
1559
- if (r.ok)
1560
- return await withSignal(r.json(), signal);
1561
- }
1562
- catch (error) {
1563
- const delay = preparationReadAttempt === undefined ? undefined : PREPARATION_READ_RETRY_DELAYS_MS[preparationReadAttempt];
1564
- if (signal.aborted || preparationReadAttempt === undefined || delay === undefined || !transientReadNetworkError(error))
1565
- throw error;
1566
- preparationReadAttempt++;
1567
- await interruptibleSleep(delay, signal);
1568
- continue;
1569
- }
1570
- // A token can be revoked or expire early; one forced refresh, then give up.
1571
- if (r.status === 401 && !retried) {
1572
- if (stopNewRequests?.aborted)
1573
- throw new PaymentOutcomeUnknownError(null, 'merchant_request_aborted');
1574
- await withSignal(this.accessToken(true), signal);
1575
- return this.call(path, { ...init, signal }, true, stopNewRequests, preparationReadAttempt);
1576
- }
1577
- throw new CheckoutApiError(r.status, path, await withSignal(r.text(), signal));
1578
- }
1579
- }
1580
- post(path, body, signal, stopNewRequests) {
1581
- return this.call(path, { method: 'POST', body: JSON.stringify(body), signal }, false, stopNewRequests);
1582
- }
1583
- get(path, signal) {
1584
- return this.call(path, { signal });
1585
- }
1586
- readPreparation(id, signal) {
1587
- return this.call(`/v2/checkout/preparations/${id}`, { signal }, false, undefined, 0);
1588
- }
1589
- }
1590
- function executionMetadata(state, authorizationId, previous = {}) {
1591
- if (state.execution_mode === undefined)
1592
- return previous;
1593
- if (state.execution_mode !== 'user_approval' && state.execution_mode !== 'autopilot')
1594
- throw new PaymentOutcomeUnknownError(authorizationId, 'execution_mode_unrecognized');
1595
- if (state.execution_mode === 'user_approval')
1596
- return { executionMode: 'user_approval' };
1597
- if (typeof state.grant_id !== 'string' || !/^apg_[A-Za-z0-9_-]{1,128}$/.test(state.grant_id))
1598
- throw new PaymentOutcomeUnknownError(authorizationId, 'autopilot_grant_unconfirmed');
1599
- if (previous.grantId && previous.grantId !== state.grant_id)
1600
- throw new PaymentOutcomeUnknownError(authorizationId, 'autopilot_grant_changed');
1601
- return { executionMode: 'autopilot', grantId: state.grant_id };
1602
- }
1603
- /**
1604
- * What authorize() creates a merchant-hosted authorization as: an Adyen cse
1605
- * payment that only a prepared checkout may make (so an unprepared request is
1606
- * refused locally with PreparationRequiredError, before any create), forwarding
1607
- * no header but content-type. Never matched against a URL: authorize() uses it
1608
- * only for an armed profile's endpoint.
1609
- */
1610
- const MERCHANT_HOSTED_RECOGNIZER = Object.freeze({
1611
- psp: 'adyen', match: /(?!)/, encoding: 'json', passthroughHeaders: [],
1612
- clientSideEncrypted: true, mode: 'cse', preparationRequired: true,
1613
- });
1614
- /**
1615
- * Refuse, before any create or prompt, a merchant-hosted request this client
1616
- * cannot pay: a profile Agentcard has not turned on (observe), Autopilot (the
1617
- * cardholder approves every one), or a request the profile's rules do not pause
1618
- * (anything but its reviewed card request).
1619
- */
1620
- function assertMerchantHostedRequest(input, profile) {
1621
- if (profile.status !== 'enabled') {
1622
- throw new Error(`Agentcard has not turned on payments to ${profile.merchant} yet. Nothing was charged.`);
1623
- }
1624
- if (input.executionMode === 'autopilot' || input.grantId !== undefined) {
1625
- throw new Error(`A payment to ${profile.merchant} needs the cardholder's approval every time; Autopilot cannot pay it.`);
1626
- }
1627
- const verdict = classifyProfileRequest(profile, input.request.url, input.request.method ?? 'POST', input.request.body);
1628
- if (verdict.verdict === 'abort')
1629
- throw new Error(`Refusing to pay ${profile.merchant}: ${verdict.reason}. Nothing was charged.`);
1630
- if (verdict.verdict === 'pass')
1631
- throw new Error(`not a card request Agentcard reviewed for ${profile.merchant}.`);
1632
- }
1633
- const isRecord = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
1634
- const FORBIDDEN_STEPS = new Set(['__proto__', 'constructor', 'prototype']);
1635
- /**
1636
- * Whether a served preparation names the sandbox declaration this client sent:
1637
- * its endpoint, client key and the test environment, with the public key's
1638
- * SHA-256 the API read. Neither side naming one also matches.
1639
- */
1640
- function servesDeclaration(served, declaration) {
1641
- if (!declaration)
1642
- return served === undefined;
1643
- return isRecord(served) && served.endpoint === declaration.endpoint && served.client_key === declaration.clientKey
1644
- && served.environment === 'test' && typeof served.public_key_sha256 === 'string' && /^[0-9a-f]{64}$/.test(served.public_key_sha256);
1645
- }
1646
- /** A served JSON path: object keys and array indices, `[]` for the body root; null for anything else. */
1647
- function jsonPathOf(value) {
1648
- if (!Array.isArray(value) || value.length > 16)
1649
- return null;
1650
- const ok = value.every((step) => (typeof step === 'string' && step.length > 0 && !FORBIDDEN_STEPS.has(step))
1651
- || (typeof step === 'number' && Number.isSafeInteger(step) && step >= 0));
1652
- return ok ? [...value] : null;
1653
- }
1654
- /** Served card facts: a list of {path, value}; null when anything in it is not one. */
1655
- function factsOf(value) {
1656
- if (!Array.isArray(value))
1657
- return null;
1658
- const facts = [];
1659
- for (const fact of value) {
1660
- if (!isRecord(fact) || Object.keys(fact).some((key) => key !== 'path' && key !== 'value'))
1661
- return null;
1662
- const path = jsonPathOf(fact.path);
1663
- if (!path || typeof fact.value !== 'string')
1664
- return null;
1665
- facts.push({ path, value: fact.value });
1666
- }
1667
- return facts;
1668
- }
1669
- const MERCHANT_HOSTED_SUBSTITUTION_MEMBERS = new Set(['encoding', 'kind', 'profile', 'path', 'fields', 'remove', 'set']);
1670
- /**
1671
- * The approved read of a merchant-hosted authorization, as the replay the
1672
- * adapters continue: the profile's substitution (`kind: 'merchant_hosted'`, a
1673
- * holder path that may be the root `[]`, the merchant's four field names with
1674
- * their ciphertext, the keys to drop and any card facts to `set`), and beside it
1675
- * the profile and the SHA-256 of the body the API checked at create. Anything
1676
- * else, a create that was not merchant-hosted, another profile, or a set the API
1677
- * no longer serves (`substitutions_expired`) is an unknown outcome: the device may
1678
- * already have encrypted the card, and nothing here can pay with it.
1679
- */
1680
- function merchantHostedApproval(s, authorizationId, profile) {
1681
- const malformed = () => new PaymentOutcomeUnknownError(authorizationId, 'cse_substitutions_malformed');
1682
- if (!profile)
1683
- throw malformed();
1684
- // The API withheld the card because the merchant's total was too old when the cardholder
1685
- // approved: nothing was encrypted into any request, so this is a refusal, not an unknown.
1686
- if (s.merchant_total_stale === true) {
1687
- throw new MerchantTotalError(authorizationId, 'merchant_total_stale', 'runtime', 'stale', `The approval came after ${profile.merchant}'s total was too old for its profile, so Agentcard withheld the card`);
1688
- }
1689
- if (s.substitutions_expired === true)
1690
- throw new PaymentOutcomeUnknownError(authorizationId, 'substitutions_expired');
1691
- const sub = s.substitutions;
1692
- const served = s.merchant_hosted;
1693
- if (!isRecord(sub) || sub.encoding !== 'json' || sub.kind !== 'merchant_hosted' || sub.profile !== profile.id
1694
- || Object.keys(sub).some((member) => !MERCHANT_HOSTED_SUBSTITUTION_MEMBERS.has(member)))
1695
- throw malformed();
1696
- if (!isRecord(served) || served.profile !== profile.id || typeof served.body_sha256 !== 'string'
1697
- || !/^[0-9a-f]{64}$/.test(served.body_sha256))
1698
- throw malformed();
1699
- const path = jsonPathOf(sub.path);
1700
- const fields = isRecord(sub.fields) && Object.keys(sub.fields).length > 0
1701
- && Object.values(sub.fields).every((value) => typeof value === 'string' && value.length > 0) ? { ...sub.fields } : null;
1702
- const remove = sub.remove === undefined ? [] : Array.isArray(sub.remove) && sub.remove.every((key) => typeof key === 'string' && key.length > 0)
1703
- ? [...sub.remove] : null;
1704
- const set = sub.set === undefined ? [] : factsOf(sub.set);
1705
- if (!path || !fields || !remove || !set)
1706
- throw malformed();
1707
- return {
1708
- mode: 'cse',
1709
- kind: 'merchant_hosted',
1710
- authorizationId,
1711
- profile: profile.id,
1712
- substitutions: { encoding: 'json', kind: 'merchant_hosted', profile: profile.id, path, fields, remove, ...(set.length ? { set } : {}) },
1713
- bodySha256: served.body_sha256,
1714
- substitutionsExpiresAt: typeof s.substitutions_expires_at === 'string' ? s.substitutions_expires_at : null,
1715
- ...(profile.sandboxDeclaration ? { sandboxDeclaration: profile.sandboxDeclaration } : {}),
1716
- };
1717
- }
1718
- /**
1719
- * Forward only the headers the merchant needs to accept the replay. Everything
1720
- * else (cookies, UA, tracing) is dropped so we transmit as little as possible.
1721
- */
1722
- function pickHeaders(headers, allow) {
1723
- const out = {};
1724
- for (const [k, v] of Object.entries(headers)) {
1725
- const lk = k.toLowerCase();
1726
- if (lk === 'content-type' || allow.some((re) => re.test(lk)))
1727
- out[lk] = v;
1728
- }
1729
- if (!out['content-type'])
1730
- out['content-type'] = 'application/json';
1731
- return out;
1732
- }
1733
- /** Settle local work even if a custom fetch implementation ignores AbortSignal. */
1734
- function withSignal(operation, signal) {
1735
- return new Promise((resolve, reject) => {
1736
- const aborted = () => { signal.removeEventListener('abort', aborted); reject(signal.reason); };
1737
- if (signal.aborted) {
1738
- operation.catch(() => { });
1739
- aborted();
1740
- return;
1741
- }
1742
- signal.addEventListener('abort', aborted, { once: true });
1743
- operation.then(value => { signal.removeEventListener('abort', aborted); resolve(value); }, error => { signal.removeEventListener('abort', aborted); reject(error); });
1744
- });
1745
- }
1746
- function interruptibleSleep(ms, signal) {
1747
- if (signal?.aborted)
1748
- return Promise.resolve();
1749
- return new Promise((resolve) => {
1750
- const done = () => { clearTimeout(timer); signal?.removeEventListener('abort', done); resolve(); };
1751
- const timer = setTimeout(done, ms);
1752
- signal?.addEventListener('abort', done, { once: true });
1753
- });
1754
- }