@agent-cards/checkout 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/PREFLIGHT.md +4 -0
  3. package/README.md +91 -7
  4. package/dist/adyen-merchant-hosted.generated.d.ts +277 -0
  5. package/dist/adyen-merchant-hosted.generated.js +1902 -0
  6. package/dist/builtin-registry.generated.js +1 -1
  7. package/dist/cdp.d.ts +4 -1
  8. package/dist/cdp.js +416 -217
  9. package/dist/client.d.ts +236 -5
  10. package/dist/client.js +514 -11
  11. package/dist/cse-body.d.ts +25 -0
  12. package/dist/cse-body.js +41 -0
  13. package/dist/fiserv.d.ts +65 -0
  14. package/dist/fiserv.generated.d.ts +73 -0
  15. package/dist/fiserv.generated.js +830 -0
  16. package/dist/fiserv.js +104 -0
  17. package/dist/index.d.ts +7 -2
  18. package/dist/index.js +5 -1
  19. package/dist/lifecycle.d.ts +15 -1
  20. package/dist/lifecycle.js +28 -3
  21. package/dist/merchant-handoff.d.ts +54 -0
  22. package/dist/merchant-handoff.js +100 -0
  23. package/dist/merchant-hosted.d.ts +140 -0
  24. package/dist/merchant-hosted.js +170 -0
  25. package/dist/merchant-total-watch.d.ts +115 -0
  26. package/dist/merchant-total-watch.js +268 -0
  27. package/dist/merchant-total.d.ts +257 -0
  28. package/dist/merchant-total.js +383 -0
  29. package/dist/pre-claim.d.ts +123 -0
  30. package/dist/pre-claim.js +386 -0
  31. package/dist/preflight-catalog.json +132 -0
  32. package/dist/preflight-schemas.json +14 -2
  33. package/dist/preflight.generated.js +15 -1
  34. package/dist/preparation.d.ts +7 -0
  35. package/dist/preparation.js +33 -6
  36. package/dist/prepared-processor.d.ts +36 -3
  37. package/dist/prepared-processor.js +53 -3
  38. package/dist/registry.d.ts +45 -0
  39. package/dist/registry.js +14 -0
  40. package/dist/stripe-checkout.generated.js +96 -7
  41. package/dist/substitutions.generated.d.ts +2 -1
  42. package/dist/substitutions.generated.js +758 -6
  43. package/examples/preflight/kernel-native/inventory.json +1 -1
  44. package/package.json +3 -3
package/dist/client.js CHANGED
@@ -1,6 +1,9 @@
1
- import { BUILTIN_REGISTRY, cardUrlPatterns as deriveCardUrlPatterns, findRecognizer, } from './registry.js';
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';
2
4
  import { isMercadoTokenRequest, parseMercadoCheckoutContext, validateMercadoProcessorContext } from './mercado-checkout.generated.js';
3
- import { matchesPreparedRequest, preparationMode, validPreparationEnvironment } from './prepared-processor.js';
5
+ import { matchesPreparation, preparationMode, validPreparationEnvironment } from './prepared-processor.js';
6
+ import { fiservKeyPinById, isFiservSubstitution, requiresFiservPreparation } from './fiserv.js';
4
7
  import { hasOwnedShopMarker, parseOwnedShopOrder, parseOwnedShopReceipt } from './owned-shop.generated.js';
5
8
  import { requestWithoutCard } from './card-fields.generated.js';
6
9
  import { classifyStripeCheckoutRequest, encodeStripeCheckoutContext, hasStripeCheckoutMarker, parseStripeCheckoutContext, parseStripeCheckoutResponse, STRIPE_CHECKOUT_CONTEXT_HEADER, } from './stripe-checkout.generated.js';
@@ -20,6 +23,17 @@ export const SUPPORTED_MODES = ['token', 'cse', 'hosted_form'];
20
23
  * confirm, which hosted Checkout sends after an approval.
21
24
  */
22
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'];
23
37
  /** An integer in the smallest unit, or a decimal string in normal units with a point; nothing else. */
24
38
  export function validAmountInput(amount) {
25
39
  if (typeof amount === 'number')
@@ -59,6 +73,22 @@ export class UnsupportedModeError extends Error {
59
73
  this.name = 'UnsupportedModeError';
60
74
  }
61
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
+ }
62
92
  export class ApprovalTimeoutError extends Error {
63
93
  constructor(ms) { super(`user did not approve within ${ms}ms`); this.name = 'ApprovalTimeoutError'; }
64
94
  }
@@ -96,6 +126,13 @@ export class ApprovalDeclinedError extends Error {
96
126
  * and quiet the page's retry exactly as for a person's "no"), so it extends
97
127
  * ApprovalDeclinedError: code that already handles declines keeps working,
98
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.
99
136
  */
100
137
  export class AmountMismatchError extends ApprovalDeclinedError {
101
138
  authorizationId;
@@ -104,20 +141,27 @@ export class AmountMismatchError extends ApprovalDeclinedError {
104
141
  currency;
105
142
  actualCurrency;
106
143
  stage;
144
+ amountSource;
107
145
  code = 'amount_mismatch';
108
146
  constructor(
109
147
  /** The declined authorization, or null for a create-time refusal (no row exists). */
110
148
  authorizationId,
111
149
  /** What the user was asked to approve, smallest currency unit. */
112
150
  expectedCents,
113
- /** What the processor reported at the last check. */
151
+ /** What the processor (or the merchant, see `amountSource`) reported at the last check. */
114
152
  actualCents,
115
153
  /** ISO 4217 of the approved amount. */
116
154
  currency,
117
155
  /** ISO 4217 the processor reported (differs only on a currency change). */
118
156
  actualCurrency = currency,
119
157
  /** Which check refused it. */
120
- stage = 'pre_replay') {
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') {
121
165
  super('amount_mismatch');
122
166
  this.authorizationId = authorizationId;
123
167
  this.expectedCents = expectedCents;
@@ -125,9 +169,12 @@ export class AmountMismatchError extends ApprovalDeclinedError {
125
169
  this.currency = currency;
126
170
  this.actualCurrency = actualCurrency;
127
171
  this.stage = stage;
172
+ this.amountSource = amountSource;
128
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';
129
176
  this.message = `amount mismatch${authorizationId ? ` on ${authorizationId}` : ' at create'}: `
130
- + `the user was asked to approve ${expectedCents} ${currency}, the processor reports ${actualCents} ${actualCurrency}. Nothing was charged.`;
177
+ + `the user was asked to approve ${expectedCents} ${currency}, ${reported} ${actualCents} ${actualCurrency}. Nothing was charged.`;
131
178
  }
132
179
  }
133
180
  /**
@@ -195,6 +242,69 @@ export class ProcessorRefusedError extends ApprovalDeclinedError {
195
242
  this.message = `the processor rejected the payment request on ${authorizationId}${pspErrorCode ? ` (${pspErrorCode})` : ''}. Check the merchant payment status before retrying.`;
196
243
  }
197
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
+ }
198
308
  /**
199
309
  * A non-2xx from the Agentcard API, carrying the status so callers can tell a
200
310
  * misconfiguration from a blip. The adapters use this to decide whether
@@ -364,6 +474,35 @@ export function redactUrl(raw) {
364
474
  return raw.split(/[?#]/)[0];
365
475
  }
366
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
+ ];
367
506
  /** Default backoff for a 502 amount_unverifiable at create: two retries, then give up. */
368
507
  const UNVERIFIABLE_RETRY_DELAYS_MS = [500, 1500];
369
508
  const PREPARATION_READ_RETRY_DELAYS_MS = [250, 500];
@@ -384,6 +523,13 @@ export class VaultClient {
384
523
  pollIntervalMs;
385
524
  unverifiableRetryDelaysMs;
386
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;
387
533
  preparations = new WeakSet();
388
534
  usedPreparations = new WeakSet();
389
535
  constructor(opts) {
@@ -393,6 +539,11 @@ export class VaultClient {
393
539
  this.registry = opts.registry ?? BUILTIN_REGISTRY;
394
540
  this.pollIntervalMs = opts.pollIntervalMs ?? 2000;
395
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);
396
547
  }
397
548
  /** Refresh recognizers from the API so new PSPs work without a redeploy. */
398
549
  async syncRegistry() {
@@ -405,19 +556,59 @@ export class VaultClient {
405
556
  // abort, which would dead-end the checkout). `mode` rides through the
406
557
  // spread verbatim.
407
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).
408
571
  try {
409
- raw = await this.get(`/v2/checkout/recognizers?modes=${SUPPORTED_MODES.join(',')}&features=${SUPPORTED_REGISTRY_FEATURES.join(',')}`);
572
+ raw = await this.get(`/v2/checkout/recognizers?modes=${SUPPORTED_MODES.join(',')}&features=${SUPPORTED_REGISTRY_FEATURES.join(',')}${capabilities}&merchant_profiles=1`);
410
573
  }
411
574
  catch {
412
575
  return;
413
576
  }
414
577
  if (!Array.isArray(raw))
415
578
  return;
416
- this.registry = raw.map((e) => ({
579
+ const isProfile = (e) => !!e && typeof e === 'object' && 'merchant_profile' in e;
580
+ this.registry = raw.filter((e) => !isProfile(e)).map((e) => ({
417
581
  ...e,
418
582
  match: new RegExp(e.match, 'i'),
419
583
  passthroughHeaders: e.passthroughHeaders.map((h) => new RegExp(h, 'i')),
420
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]);
421
612
  }
422
613
  /** True when this request is a card tokenization we can take over. */
423
614
  isCardRequest(url, method = 'POST') {
@@ -471,6 +662,35 @@ export class VaultClient {
471
662
  const fail = (reason, id = null) => new CheckoutPreparationError(id, reason);
472
663
  if (!validPreparationEnvironment(input.psp, input.environment))
473
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;
474
694
  if (!validAmountInput(input.amount) || typeof input.currency !== 'string' || !/^[a-z]{3}$/i.test(input.currency))
475
695
  throw fail('amount_required');
476
696
  const origin = new URL(input.merchantOrigin);
@@ -478,6 +698,11 @@ export class VaultClient {
478
698
  throw fail('merchant_origin_invalid');
479
699
  if (!input.checkoutKey || !input.user || !input.merchant)
480
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');
481
706
  const timeoutMs = input.timeoutMs ?? 15 * 60_000;
482
707
  if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647)
483
708
  throw fail('timeout_invalid');
@@ -493,6 +718,9 @@ export class VaultClient {
493
718
  user: input.user, merchant: input.merchant, amount: input.amount, currency: input.currency.toLowerCase(),
494
719
  ...(input.cardId ? { card_id: input.cardId } : {}), psp: input.psp, mode: preparationMode(input.psp),
495
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' } } : {}),
496
724
  }, AbortSignal.timeout(30_000), signal);
497
725
  if (!created || typeof created.id !== 'string' || !/^cprep_[A-Za-z0-9_-]{1,128}$/.test(created.id))
498
726
  throw fail('create_unconfirmed');
@@ -520,7 +748,12 @@ export class VaultClient {
520
748
  const expiry = Date.parse(state.ready_expires_at);
521
749
  if (!Number.isFinite(expiry) || expiry <= Date.now() || typeof state.card_id !== 'string' || !state.card_id
522
750
  || state.payment_status !== 'not_started' || state.amount_authority !== 'agent'
523
- || state.user !== input.user || state.merchant !== input.merchant || state.merchant_origin !== input.merchantOrigin
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)
524
757
  || !Number.isSafeInteger(state.amount) || (typeof input.amount === 'number' && state.amount !== input.amount) || state.currency !== input.currency.toLowerCase()
525
758
  || state.psp !== input.psp || state.mode !== preparationMode(input.psp) || state.environment !== input.environment
526
759
  || state.checkout_key !== input.checkoutKey)
@@ -531,6 +764,9 @@ export class VaultClient {
531
764
  amountDisplay: typeof state.amount_display === 'string' ? state.amount_display : null,
532
765
  currency: input.currency.toLowerCase(), merchantOrigin: input.merchantOrigin, checkoutKey: input.checkoutKey,
533
766
  paymentStatus: 'not_started', amountAuthority: 'agent',
767
+ ...(profile ? { merchantProfile: profile.id } : {}),
768
+ ...(fiservPin ? { merchantProfile: fiservPin.id } : {}),
769
+ ...(declaration ? { sandboxDeclaration: declaration } : {}),
534
770
  });
535
771
  this.preparations.add(prepared);
536
772
  ready = true;
@@ -545,6 +781,15 @@ export class VaultClient {
545
781
  catch (error) {
546
782
  if (error instanceof CheckoutPreparationError)
547
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);
548
793
  throw fail(signal.aborted ? 'cancelled' : 'preparation_unconfirmed', id);
549
794
  }
550
795
  finally {
@@ -637,14 +882,33 @@ export class VaultClient {
637
882
  throw new CheckoutPreparationError(preparation.id, 'expired');
638
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))
639
884
  || input.currency?.toLowerCase() !== preparation.currency || input.cardId !== preparation.cardId
640
- || !matchesPreparedRequest(preparation.psp, preparation.environment, input.request.url, input.request.method ?? 'POST', input.request.body, input.request.headers))
885
+ || !matchesPreparation(preparation, input.request.url, input.request.method ?? 'POST', input.request.body, input.request.headers))
641
886
  throw new CheckoutPreparationError(preparation.id, 'checkout_changed');
642
887
  }
643
888
  if (input.signal?.aborted)
644
889
  throw new CheckoutCancelledError();
645
890
  if (input.merchantSignal?.aborted)
646
891
  throw new PaymentOutcomeUnknownError(null, 'merchant_request_aborted');
647
- const rec = nativeCheckout ? this.registry.find(entry => entry.psp === 'stripe' && (entry.mode ?? 'token') === 'token')
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')
648
912
  : findRecognizer(input.request.url, this.registry);
649
913
  if (!rec)
650
914
  throw new Error(`not a known tokenization endpoint: ${redactUrl(input.request.url)}`);
@@ -655,6 +919,22 @@ export class VaultClient {
655
919
  const mode = rec.mode ?? 'token';
656
920
  if (preparation && (rec.psp !== preparation.psp || mode !== preparationMode(preparation.psp)))
657
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
+ }
658
938
  if (rec.clientSideEncrypted && mode !== 'cse')
659
939
  throw new CardEncryptedError(rec.psp);
660
940
  if (!SUPPORTED_MODES.includes(mode))
@@ -686,6 +966,10 @@ export class VaultClient {
686
966
  const deadline = Date.now() + timeoutMs;
687
967
  const timeoutSignal = AbortSignal.timeout(timeoutMs);
688
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;
689
973
  let created;
690
974
  const payload = {
691
975
  user: input.user,
@@ -704,6 +988,12 @@ export class VaultClient {
704
988
  ...(input.executionMode ? { execution_mode: input.executionMode } : {}),
705
989
  ...(input.grantId ? { grant_id: input.grantId } : {}),
706
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 } : {}) } } : {}),
707
997
  // A prepared checkout already carries the page origin as merchant_origin.
708
998
  ...(preparation
709
999
  ? { preparation_id: preparation.id, checkout_key: preparation.checkoutKey, merchant_origin: preparation.merchantOrigin, form_guidance: 'filled' }
@@ -831,6 +1121,13 @@ export class VaultClient {
831
1121
  const amountAuthority = typeof s.amount_authority === 'string' && AMOUNT_AUTHORITIES.includes(s.amount_authority)
832
1122
  ? { amountAuthority: s.amount_authority }
833
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
+ }
834
1131
  if (s.status === 'submitted_on_device') {
835
1132
  // The device attested that the processor's form left it; the stamp
836
1133
  // is the whole fact and it is NOT an approval (see HostedFormReplay).
@@ -858,12 +1155,27 @@ export class VaultClient {
858
1155
  if (approvedMode === 'cse') {
859
1156
  if (execution.executionMode === 'autopilot')
860
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');
861
1169
  const sub = s.substitutions;
862
1170
  const fieldsOk = sub && typeof sub === 'object' && sub.encoding === 'json' && typeof sub.at === 'string' && sub.at
863
1171
  && sub.fields && typeof sub.fields === 'object' && !Array.isArray(sub.fields)
864
1172
  && Object.values(sub.fields).every((v) => typeof v === 'string' && v.length > 0);
865
1173
  if (!fieldsOk)
866
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');
867
1179
  // `remove`: sibling keys the API says to drop with the swap (Adyen's
868
1180
  // `brand`, stamped by adyen-web from the agent's dummy digits). Absent
869
1181
  // on an older API; anything but a list of names is refused, since a
@@ -950,6 +1262,9 @@ export class VaultClient {
950
1262
  }
951
1263
  if (s.reason === 'intent_not_confirmable')
952
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
+ }
953
1268
  if (typeof s.reason === 'string' && PRESET_REFUSAL_REASONS.has(s.reason)) {
954
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));
955
1270
  }
@@ -1071,6 +1386,67 @@ export class VaultClient {
1071
1386
  }
1072
1387
  return { paymentIntentId: pi.id, amount: pi.amount, currency: pi.currency };
1073
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
+ }
1074
1450
  /**
1075
1451
  * POST the create, with two typed twists: a 502 `amount_unverifiable`
1076
1452
  * (Stripe did not answer the read-back) is retried on a short backoff
@@ -1091,9 +1467,21 @@ export class VaultClient {
1091
1467
  const d = err.details;
1092
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));
1093
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
+ }
1094
1474
  if (err instanceof CheckoutApiError && err.code === 'amount_mismatch') {
1095
1475
  const d = err.details;
1096
- 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');
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);
1097
1485
  }
1098
1486
  // Two 502s the API asks to be retried: Stripe did not answer the
1099
1487
  // amount read-back, or Adyen did not answer the public-key fetch.
@@ -1212,6 +1600,121 @@ function executionMetadata(state, authorizationId, previous = {}) {
1212
1600
  throw new PaymentOutcomeUnknownError(authorizationId, 'autopilot_grant_changed');
1213
1601
  return { executionMode: 'autopilot', grantId: state.grant_id };
1214
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
+ }
1215
1718
  /**
1216
1719
  * Forward only the headers the merchant needs to accept the replay. Everything
1217
1720
  * else (cookies, UA, tracing) is dropped so we transmit as little as possible.