@agent-cards/checkout 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.js CHANGED
@@ -1,4 +1,11 @@
1
- import { BUILTIN_REGISTRY, findRecognizer } from './registry.js';
1
+ import { BUILTIN_REGISTRY, cardUrlPatterns as deriveCardUrlPatterns, findRecognizer, } from './registry.js';
2
+ /**
3
+ * The modes this SDK can finish. Asked for on syncRegistry (the API serves
4
+ * only recognizers in these modes, so a request this build cannot complete
5
+ * is never paused) and sent on every create.
6
+ */
7
+ export const SUPPORTED_MODES = ['token', 'cse', 'hosted_form'];
8
+ const AMOUNT_AUTHORITIES = ['stripe_payment_intent', 'hosted_form_sum', 'display_only'];
2
9
  export class CardEncryptedError extends Error {
3
10
  psp;
4
11
  constructor(psp) {
@@ -8,17 +15,200 @@ export class CardEncryptedError extends Error {
8
15
  this.name = 'CardEncryptedError';
9
16
  }
10
17
  }
18
+ /**
19
+ * The API approved an authorization in a mode this SDK build cannot finish.
20
+ * Unreachable by construction (syncRegistry asks only for SUPPORTED_MODES and
21
+ * every create names its mode), so it is treated as terminal: retrying the
22
+ * same page could only raise more prompts for the same dead end.
23
+ */
24
+ export class UnsupportedModeError extends Error {
25
+ mode;
26
+ constructor(mode) {
27
+ super(`the authorization was approved in mode "${mode}", which this version of @agent-cards/checkout cannot complete; upgrade the SDK.`);
28
+ this.mode = mode;
29
+ this.name = 'UnsupportedModeError';
30
+ }
31
+ }
11
32
  export class ApprovalTimeoutError extends Error {
12
33
  constructor(ms) { super(`user did not approve within ${ms}ms`); this.name = 'ApprovalTimeoutError'; }
13
34
  }
14
35
  export class ApprovalDeclinedError extends Error {
15
36
  constructor(reason) { super(`user declined: ${reason}`); this.name = 'ApprovalDeclinedError'; }
16
37
  }
38
+ /**
39
+ * Agentcard refused the payment because the processor's amount did not match
40
+ * the amount the user was (or would have been) asked to approve. Nothing was
41
+ * charged. Two stages:
42
+ * - 'create': the intent already disagreed when the request was parked. No
43
+ * authorization exists (`authorizationId` is null). Per request, not per
44
+ * page: the merchant can still update the intent before confirmation, so
45
+ * the adapters keep intercepting and the next attempt is judged afresh.
46
+ * - 'pre_replay': the intent moved between create and the moment the
47
+ * cardholder's device would have sent the card. The authorization is
48
+ * `declined` with reason `amount_mismatch`.
49
+ *
50
+ * A decline in every structural sense (the adapters abort the paused request
51
+ * and quiet the page's retry exactly as for a person's "no"), so it extends
52
+ * ApprovalDeclinedError: code that already handles declines keeps working,
53
+ * and code that wants the numbers reads them here or branches on `code`.
54
+ */
55
+ export class AmountMismatchError extends ApprovalDeclinedError {
56
+ authorizationId;
57
+ expectedCents;
58
+ actualCents;
59
+ currency;
60
+ actualCurrency;
61
+ stage;
62
+ code = 'amount_mismatch';
63
+ constructor(
64
+ /** The declined authorization, or null for a create-time refusal (no row exists). */
65
+ authorizationId,
66
+ /** What the user was asked to approve, smallest currency unit. */
67
+ expectedCents,
68
+ /** What the processor reported at the last check. */
69
+ actualCents,
70
+ /** ISO 4217 of the approved amount. */
71
+ currency,
72
+ /** ISO 4217 the processor reported (differs only on a currency change). */
73
+ actualCurrency = currency,
74
+ /** Which check refused it. */
75
+ stage = 'pre_replay') {
76
+ super('amount_mismatch');
77
+ this.authorizationId = authorizationId;
78
+ this.expectedCents = expectedCents;
79
+ this.actualCents = actualCents;
80
+ this.currency = currency;
81
+ this.actualCurrency = actualCurrency;
82
+ this.stage = stage;
83
+ this.name = 'AmountMismatchError';
84
+ this.message = `amount mismatch${authorizationId ? ` on ${authorizationId}` : ' at create'}: `
85
+ + `the user was asked to approve ${expectedCents} ${currency}, the processor reports ${actualCents} ${actualCurrency}. Nothing was charged.`;
86
+ }
87
+ }
88
+ /**
89
+ * The PaymentIntent behind this checkout can no longer be confirmed: it was
90
+ * already charged (succeeded), is being charged (processing), is authorized
91
+ * and on hold for the merchant to capture (requires_capture), or was
92
+ * canceled. Agentcard refused rather than replay a confirm at it; the
93
+ * authorization is `declined` with reason `intent_not_confirmable`.
94
+ * Deliberately NOT "nothing was charged": for three of those four, money has
95
+ * moved or is moving. Check the intent at Stripe before retrying.
96
+ */
97
+ export class IntentNotConfirmableError extends ApprovalDeclinedError {
98
+ authorizationId;
99
+ code = 'intent_not_confirmable';
100
+ constructor(authorizationId) {
101
+ super('intent_not_confirmable');
102
+ this.authorizationId = authorizationId;
103
+ this.name = 'IntentNotConfirmableError';
104
+ this.message = `the PaymentIntent behind ${authorizationId} was already charged, is processing, or is on hold; `
105
+ + 'it cannot be confirmed again. Check the intent at Stripe before retrying.';
106
+ }
107
+ }
108
+ /**
109
+ * The cardholder's device sent the card and the processor refused it
110
+ * outright (a card decline, a bad CVC, an invalid request). Nothing was
111
+ * charged; the authorization is `declined` with reason `processor_refused`
112
+ * and the processor's own code in `pspErrorCode` (Stripe's `card_declined`,
113
+ * `incorrect_cvc`, …). A decline like any other for the adapters: the paused
114
+ * request is aborted and the page's retry gets a fresh approval, where the
115
+ * person can pick another card.
116
+ */
117
+ export class ProcessorRefusedError extends ApprovalDeclinedError {
118
+ authorizationId;
119
+ pspErrorCode;
120
+ code = 'processor_refused';
121
+ constructor(authorizationId, pspErrorCode) {
122
+ super('processor_refused');
123
+ this.authorizationId = authorizationId;
124
+ this.pspErrorCode = pspErrorCode;
125
+ this.name = 'ProcessorRefusedError';
126
+ this.message = `the processor refused the card on ${authorizationId}${pspErrorCode ? ` (${pspErrorCode})` : ''}. Nothing was charged.`;
127
+ }
128
+ }
129
+ /**
130
+ * A non-2xx from the Agentcard API, carrying the status so callers can tell a
131
+ * misconfiguration from a blip. The adapters use this to decide whether
132
+ * retrying is worth anything: a 404 `connection_not_found` will answer the same
133
+ * way forever, while a 429 or a 502 will not.
134
+ */
135
+ export class CheckoutApiError extends Error {
136
+ status;
137
+ path;
138
+ bodyText;
139
+ /** The API's stable error code (`{ error: { code } }`), or null when the body carried none. */
140
+ code;
141
+ /**
142
+ * The rest of the error envelope. A 409 `amount_mismatch` from create
143
+ * carries `expected_cents`, `actual_cents`, `currency`, `actual_currency`;
144
+ * an `amount_unverifiable` carries `reason`; an `intent_not_confirmable`
145
+ * carries `intent_status`.
146
+ */
147
+ details;
148
+ constructor(status, path, bodyText) {
149
+ super(`agentcard ${path} -> ${status} ${bodyText}`);
150
+ this.status = status;
151
+ this.path = path;
152
+ this.bodyText = bodyText;
153
+ this.name = 'CheckoutApiError';
154
+ const envelope = parseErrorEnvelope(bodyText);
155
+ this.code = envelope.code;
156
+ this.details = envelope.details;
157
+ }
158
+ /**
159
+ * True when repeating this exact call cannot succeed: a misconfiguration
160
+ * (4xx other than 429). NOT a 409 `amount_mismatch`: Stripe lets a merchant
161
+ * update an intent's amount until it is confirmed, so the next request on
162
+ * the same page may well agree. That one is a per-request failure, and
163
+ * authorize() surfaces it as AmountMismatchError before an adapter ever
164
+ * sees it here. NOT a 409 `duplicate_submission` either: the household
165
+ * already has, or already answered, the prompt for this submission, and
166
+ * once that prior authorization is declined or expired the same form is a
167
+ * new question. The adapters quiet the page's re-post the way they quiet a
168
+ * decline instead of latching the attachment.
169
+ */
170
+ get permanent() {
171
+ if (this.code === 'amount_mismatch' || this.code === 'duplicate_submission')
172
+ return false;
173
+ return this.status >= 400 && this.status < 500 && this.status !== 429;
174
+ }
175
+ }
176
+ function parseErrorEnvelope(bodyText) {
177
+ try {
178
+ const parsed = JSON.parse(bodyText);
179
+ const err = parsed && typeof parsed === 'object' ? parsed.error : null;
180
+ if (err && typeof err === 'object') {
181
+ const { code, ...details } = err;
182
+ return { code: typeof code === 'string' ? code : null, details };
183
+ }
184
+ }
185
+ catch {
186
+ // Not a JSON envelope (a proxy page, an empty body): no code to carry.
187
+ }
188
+ return { code: null, details: {} };
189
+ }
190
+ /**
191
+ * A URL as it may appear in an error message: origin + path only. A paused
192
+ * request's URL can carry a client secret in its query string, and error
193
+ * messages travel further than anyone intends (onEvent, logs, crash reports).
194
+ */
195
+ export function redactUrl(raw) {
196
+ try {
197
+ const u = new URL(raw);
198
+ return `${u.origin}${u.pathname}`;
199
+ }
200
+ catch {
201
+ return raw.split(/[?#]/)[0];
202
+ }
203
+ }
204
+ /** Default backoff for a 502 amount_unverifiable at create: two retries, then give up. */
205
+ const UNVERIFIABLE_RETRY_DELAYS_MS = [500, 1500];
17
206
  export class VaultClient {
18
207
  opts;
19
208
  baseUrl;
20
209
  fetch;
21
210
  pollIntervalMs;
211
+ unverifiableRetryDelaysMs;
22
212
  registry;
23
213
  constructor(opts) {
24
214
  this.opts = opts;
@@ -26,14 +216,21 @@ export class VaultClient {
26
216
  this.fetch = opts.fetchImpl ?? globalThis.fetch;
27
217
  this.registry = opts.registry ?? BUILTIN_REGISTRY;
28
218
  this.pollIntervalMs = opts.pollIntervalMs ?? 2000;
219
+ this.unverifiableRetryDelaysMs = opts.unverifiableRetryDelaysMs ?? UNVERIFIABLE_RETRY_DELAYS_MS;
29
220
  }
30
221
  /** Refresh recognizers from the API so new PSPs work without a redeploy. */
31
222
  async syncRegistry() {
32
223
  // Never break checkout over a registry fetch — an auth blip or a bad
33
224
  // response leaves the built-in recognizers in place.
225
+ //
226
+ // ?modes= is capability negotiation: the API serves only recognizers in
227
+ // the modes this build can finish, so a processor whose flow this SDK
228
+ // does not speak is never armed (a pause it cannot complete becomes an
229
+ // abort, which would dead-end the checkout). `mode` rides through the
230
+ // spread verbatim.
34
231
  let raw;
35
232
  try {
36
- raw = await this.get('/v2/checkout/recognizers');
233
+ raw = await this.get(`/v2/checkout/recognizers?modes=${SUPPORTED_MODES.join(',')}`);
37
234
  }
38
235
  catch {
39
236
  return;
@@ -50,6 +247,20 @@ export class VaultClient {
50
247
  isCardRequest(url, method = 'POST') {
51
248
  return method.toUpperCase() === 'POST' && findRecognizer(url, this.registry) !== null;
52
249
  }
250
+ /**
251
+ * Glob url patterns covering every host the CURRENT registry can send a card
252
+ * to — what a raw CDP connection has to hand `Fetch.enable` before any card
253
+ * request can be paused. attachToCdp calls this for you.
254
+ *
255
+ * Call syncRegistry() FIRST: without it these cover only the built-in PSPs,
256
+ * and a request the API knows about is never paused at all. Deliberately
257
+ * WIDER than the recognizers themselves (a glob cannot express an anchored
258
+ * host regex); isCardRequest is the exact check and runs on every request
259
+ * these patterns pause.
260
+ */
261
+ cardUrlPatterns() {
262
+ return deriveCardUrlPatterns(this.registry);
263
+ }
53
264
  /**
54
265
  * Hand us a paused tokenization request. We ask the cardholder to approve,
55
266
  * their device supplies the card and calls the merchant, and you get back the
@@ -58,34 +269,171 @@ export class VaultClient {
58
269
  async authorize(input) {
59
270
  const rec = findRecognizer(input.request.url, this.registry);
60
271
  if (!rec)
61
- throw new Error(`not a known tokenization endpoint: ${input.request.url}`);
62
- if (rec.clientSideEncrypted)
272
+ throw new Error(`not a known tokenization endpoint: ${redactUrl(input.request.url)}`);
273
+ // A client-side-encrypted processor is only takeable when its entry says
274
+ // the vault produces that ciphertext itself (mode cse); a bare
275
+ // clientSideEncrypted entry (an older registry, a hand-built one) still
276
+ // refuses here, exactly as before.
277
+ const mode = rec.mode ?? 'token';
278
+ if (rec.clientSideEncrypted && mode !== 'cse')
63
279
  throw new CardEncryptedError(rec.psp);
280
+ if (!SUPPORTED_MODES.includes(mode))
281
+ throw new UnsupportedModeError(mode);
282
+ // Say no rather than coerce. isCardRequest only ever pauses a POST, and the
283
+ // service stores the replay template with method 'POST' hardcoded — so a
284
+ // caller handing in a PUT or GET would get it silently rewritten and the
285
+ // cardholder's device would replay something the caller never asked for.
286
+ // A direct caller deserves to be told, not surprised.
287
+ const method = (input.request.method ?? 'POST').toUpperCase();
288
+ if (method !== 'POST') {
289
+ throw new Error(`checkout authorization is POST-only; got ${method} for ${redactUrl(input.request.url)}. ` +
290
+ 'A non-POST tokenizer needs recognizer support before it can be intercepted.');
291
+ }
292
+ // The amount travels as a display string, a number with its currency, or
293
+ // both. Refuse half a pair here, before a network call: the API would too,
294
+ // and a retry loop cannot fix a missing field.
295
+ const hasCents = input.amountCents != null;
296
+ const hasCurrency = typeof input.currency === 'string' && input.currency.length > 0;
297
+ if (hasCents !== hasCurrency) {
298
+ throw new Error('amountCents and currency go together: pass both or neither.');
299
+ }
300
+ if (!input.amount && !hasCents) {
301
+ throw new Error('authorize needs amount (a display string), or amountCents with currency.');
302
+ }
64
303
  const timeoutMs = input.timeoutMs ?? 15 * 60_000;
65
- const created = await this.post('/v2/checkout/authorizations', {
304
+ const created = await this.createAuthorization({
66
305
  user: input.user,
67
306
  merchant: input.merchant,
68
- amount: input.amount,
307
+ ...(input.amount ? { amount: input.amount } : {}),
308
+ // snake_case on the wire; camelCase is this SDK's convention.
309
+ ...(hasCents ? { amount_cents: input.amountCents, currency: input.currency } : {}),
69
310
  psp: rec.psp,
311
+ // The mode this request will be finished in. The API checks it against
312
+ // the recognizer and refuses a disagreement before a row exists.
313
+ mode,
314
+ ...(input.cardId ? { cardId: input.cardId } : {}),
70
315
  request: {
71
316
  url: input.request.url,
72
317
  method: input.request.method,
73
318
  headers: pickHeaders(input.request.headers, rec.passthroughHeaders),
74
319
  body: input.request.body,
75
320
  },
76
- });
321
+ }, input.currency);
77
322
  input.onApprovalUrl?.(created.approvalUrl);
323
+ const authorizationId = String(created.id);
78
324
  const deadline = Date.now() + timeoutMs;
79
325
  while (Date.now() < deadline) {
80
326
  await sleep(this.pollIntervalMs);
81
- const s = await this.get(`/v2/checkout/authorizations/${created.id}`);
82
- if (s.status === 'approved')
83
- return s.response;
84
- if (s.status === 'declined')
327
+ const s = await this.get(`/v2/checkout/authorizations/${authorizationId}`);
328
+ const amountAuthority = typeof s.amount_authority === 'string' && AMOUNT_AUTHORITIES.includes(s.amount_authority)
329
+ ? { amountAuthority: s.amount_authority }
330
+ : {};
331
+ if (s.status === 'submitted_on_device') {
332
+ // The device attested that the processor's form left it; the stamp
333
+ // is the whole fact and it is NOT an approval (see HostedFormReplay).
334
+ // Only a hosted_form row may carry this status; a stamp without its
335
+ // time is not one this SDK can act on (not permanent: the next poll,
336
+ // or request, may carry it).
337
+ const mode = typeof s.mode === 'string' ? s.mode : 'token';
338
+ if (mode !== 'hosted_form')
339
+ throw new Error(`malformed authorization ${authorizationId}: submitted_on_device on mode ${mode}`);
340
+ if (typeof s.submitted_at !== 'string' || !s.submitted_at) {
341
+ throw new Error(`malformed hosted_form submission on authorization ${authorizationId}: no submitted_at`);
342
+ }
343
+ return { mode: 'hosted_form', kind: 'submitted_on_device', outcome: 'unverified', authorizationId, submittedAt: s.submitted_at, ...amountAuthority };
344
+ }
345
+ if (s.status === 'approved') {
346
+ const approvedMode = typeof s.mode === 'string' ? s.mode : 'token';
347
+ if (approvedMode === 'hosted_form') {
348
+ // Never: the API finishes a hosted form as submitted_on_device, and
349
+ // its database refuses `approved` on that mode. An answer that says
350
+ // otherwise is not one to act on as a payment.
351
+ throw new Error(`malformed authorization ${authorizationId}: a hosted_form authorization finishes as submitted_on_device, never approved`);
352
+ }
353
+ if (approvedMode === 'cse') {
354
+ const sub = s.substitutions;
355
+ const fieldsOk = sub && typeof sub === 'object' && sub.encoding === 'json' && typeof sub.at === 'string' && sub.at
356
+ && sub.fields && typeof sub.fields === 'object' && !Array.isArray(sub.fields)
357
+ && Object.values(sub.fields).every((v) => typeof v === 'string' && v.length > 0);
358
+ if (!fieldsOk)
359
+ throw new Error(`malformed substitutions on approved authorization ${authorizationId}`);
360
+ // `remove`: sibling keys the API says to drop with the swap (Adyen's
361
+ // `brand`, stamped by adyen-web from the agent's dummy digits). Absent
362
+ // on an older API; anything but a list of names is refused, since a
363
+ // half-understood instruction would continue a body Adyen refuses.
364
+ const removeRaw = sub.remove;
365
+ if (removeRaw !== undefined && !(Array.isArray(removeRaw) && removeRaw.every((k) => typeof k === 'string' && k.length > 0))) {
366
+ throw new Error(`malformed substitutions.remove on approved authorization ${authorizationId}`);
367
+ }
368
+ return {
369
+ mode: 'cse',
370
+ authorizationId,
371
+ substitutions: {
372
+ encoding: 'json',
373
+ at: sub.at,
374
+ fields: { ...sub.fields },
375
+ ...(removeRaw ? { remove: [...removeRaw] } : {}),
376
+ },
377
+ ...amountAuthority,
378
+ };
379
+ }
380
+ if (approvedMode !== 'token')
381
+ throw new UnsupportedModeError(approvedMode);
382
+ const response = s.response;
383
+ return {
384
+ mode: 'token',
385
+ authorizationId,
386
+ ...response,
387
+ amountVerified: typeof s.amount_verified === 'boolean' ? s.amount_verified : null,
388
+ chargedAmountCents: typeof s.charged_amount_cents === 'number' ? s.charged_amount_cents : null,
389
+ chargedCurrency: typeof s.charged_currency === 'string' ? s.charged_currency : null,
390
+ chargedKind: s.charged_kind === 'captured' || s.charged_kind === 'authorized' || s.charged_kind === 'none' ? s.charged_kind : null,
391
+ ...amountAuthority,
392
+ };
393
+ }
394
+ if (s.status === 'declined') {
395
+ // The pre-replay checks declined it: typed, with the numbers, so the
396
+ // caller can say what happened rather than "the user said no".
397
+ if (s.reason === 'amount_mismatch') {
398
+ 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');
399
+ }
400
+ if (s.reason === 'intent_not_confirmable')
401
+ throw new IntentNotConfirmableError(String(created.id));
402
+ if (s.reason === 'processor_refused') {
403
+ throw new ProcessorRefusedError(String(created.id), typeof s.psp_error_code === 'string' ? s.psp_error_code : null);
404
+ }
85
405
  throw new ApprovalDeclinedError(s.reason ?? 'no reason given');
406
+ }
86
407
  }
87
408
  throw new ApprovalTimeoutError(timeoutMs);
88
409
  }
410
+ /**
411
+ * POST the create, with two typed twists: a 502 `amount_unverifiable`
412
+ * (Stripe did not answer the read-back) is retried on a short backoff
413
+ * instead of being left to the page's own retry loop, and a 409
414
+ * `amount_mismatch` becomes an AmountMismatchError at stage 'create' so the
415
+ * adapters treat it as an answered request, not a dead page.
416
+ */
417
+ async createAuthorization(payload, currency) {
418
+ for (let attempt = 0;; attempt++) {
419
+ try {
420
+ return await this.post('/v2/checkout/authorizations', payload);
421
+ }
422
+ catch (err) {
423
+ if (err instanceof CheckoutApiError && err.code === 'amount_mismatch') {
424
+ const d = err.details;
425
+ 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');
426
+ }
427
+ // Two 502s the API asks to be retried: Stripe did not answer the
428
+ // amount read-back, or Adyen did not answer the public-key fetch.
429
+ const retryable = err instanceof CheckoutApiError && err.status === 502
430
+ && (err.code === 'amount_unverifiable' || err.code === 'cse_key_unavailable');
431
+ if (!retryable || attempt >= this.unverifiableRetryDelaysMs.length)
432
+ throw err;
433
+ await sleep(this.unverifiableRetryDelaysMs[attempt]);
434
+ }
435
+ }
436
+ }
89
437
  // --- auth: client_credentials, cached until just before it expires --------
90
438
  token = null;
91
439
  inflight = null;
@@ -136,7 +484,7 @@ export class VaultClient {
136
484
  return this.call(path, init, true);
137
485
  }
138
486
  if (!r.ok)
139
- throw new Error(`agentcard ${path} -> ${r.status} ${await r.text()}`);
487
+ throw new CheckoutApiError(r.status, path, await r.text());
140
488
  return r.json();
141
489
  }
142
490
  post(path, body) {
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The hosted_form half of a replay: what the paused navigation is resolved
3
+ * with once the cardholder's device has submitted the processor's own form.
4
+ *
5
+ * On a hosted-form processor (Tranzila) the paused request is a top-level
6
+ * form navigation inside the processor's iframe, not an XHR. The cardholder's
7
+ * device rebuilds that form with the real card and submits it itself, so the
8
+ * processor's answer (approved, declined) is shown on the device and reaches
9
+ * the merchant from the processor. The agent's browser never gets it.
10
+ *
11
+ * The paused navigation still has to resolve to SOMETHING. Aborting it is the
12
+ * wrong something: an aborted navigation renders no error page, the iframe
13
+ * silently stays on the dummy-card form, and the agent's natural next move is
14
+ * to click Pay again. A fake copy of the processor's result page is wrong too:
15
+ * this SDK does not know the outcome or the merchant's contract, so it must
16
+ * not claim one. What it CAN say is exactly what happened: the cardholder
17
+ * completed this payment on their own device, do not resubmit, confirm the
18
+ * order with the merchant. This page says that, for a person and for an
19
+ * agent, and nothing else.
20
+ */
21
+ export interface HostedFormSubmittedPageInput {
22
+ /** The approved authorization (`cauth_…`). */
23
+ authorizationId: string;
24
+ /** The merchant name the authorization was created with. */
25
+ merchant: string;
26
+ /** When the device reported the form left it (ISO 8601). */
27
+ submittedAt: string;
28
+ }
29
+ export interface SyntheticPage {
30
+ status: 200;
31
+ headers: Record<string, string>;
32
+ body: string;
33
+ }
34
+ /** The outcome word on the page, the header and the JSON: one spelling everywhere. */
35
+ export declare const HOSTED_FORM_SUBMITTED_OUTCOME = "submitted_on_device";
36
+ /**
37
+ * A self-contained HTML document: no external resources, no script that runs
38
+ * (the one `<script>` is `application/json`, inert by type), machine-readable
39
+ * through the meta tag, the header and the JSON block, and human-readable in
40
+ * one paragraph. `merchant` is integrator text and lands escaped; inside the
41
+ * JSON every `<`, `>` and `&` is a \u escape (still valid JSON), so a merchant
42
+ * name can never close the script block early or open a comment inside it.
43
+ */
44
+ export declare function hostedFormSubmittedPage(input: HostedFormSubmittedPageInput): SyntheticPage;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * The hosted_form half of a replay: what the paused navigation is resolved
3
+ * with once the cardholder's device has submitted the processor's own form.
4
+ *
5
+ * On a hosted-form processor (Tranzila) the paused request is a top-level
6
+ * form navigation inside the processor's iframe, not an XHR. The cardholder's
7
+ * device rebuilds that form with the real card and submits it itself, so the
8
+ * processor's answer (approved, declined) is shown on the device and reaches
9
+ * the merchant from the processor. The agent's browser never gets it.
10
+ *
11
+ * The paused navigation still has to resolve to SOMETHING. Aborting it is the
12
+ * wrong something: an aborted navigation renders no error page, the iframe
13
+ * silently stays on the dummy-card form, and the agent's natural next move is
14
+ * to click Pay again. A fake copy of the processor's result page is wrong too:
15
+ * this SDK does not know the outcome or the merchant's contract, so it must
16
+ * not claim one. What it CAN say is exactly what happened: the cardholder
17
+ * completed this payment on their own device, do not resubmit, confirm the
18
+ * order with the merchant. This page says that, for a person and for an
19
+ * agent, and nothing else.
20
+ */
21
+ /** The outcome word on the page, the header and the JSON: one spelling everywhere. */
22
+ export const HOSTED_FORM_SUBMITTED_OUTCOME = 'submitted_on_device';
23
+ function escapeHtml(s) {
24
+ return String(s)
25
+ .replace(/&/g, '&amp;')
26
+ .replace(/</g, '&lt;')
27
+ .replace(/>/g, '&gt;')
28
+ .replace(/"/g, '&quot;')
29
+ .replace(/'/g, '&#39;');
30
+ }
31
+ /**
32
+ * A self-contained HTML document: no external resources, no script that runs
33
+ * (the one `<script>` is `application/json`, inert by type), machine-readable
34
+ * through the meta tag, the header and the JSON block, and human-readable in
35
+ * one paragraph. `merchant` is integrator text and lands escaped; inside the
36
+ * JSON every `<`, `>` and `&` is a \u escape (still valid JSON), so a merchant
37
+ * name can never close the script block early or open a comment inside it.
38
+ */
39
+ export function hostedFormSubmittedPage(input) {
40
+ const merchant = String(input.merchant ?? '');
41
+ const record = {
42
+ outcome: HOSTED_FORM_SUBMITTED_OUTCOME,
43
+ // In words, so an agent reading this block cannot take it for a receipt:
44
+ // the cardholder's device attested the form left it, nothing more.
45
+ payment: 'unverified',
46
+ attested_by: 'cardholder_device',
47
+ authorization_id: String(input.authorizationId),
48
+ merchant,
49
+ submitted_at: String(input.submittedAt),
50
+ next: "poll the merchant's order state; never resubmit",
51
+ };
52
+ const json = JSON.stringify(record).replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/&/g, '\\u0026');
53
+ const body = [
54
+ '<!doctype html>',
55
+ '<html lang="en">',
56
+ '<head>',
57
+ '<meta charset="utf-8">',
58
+ `<meta name="agentcard-checkout" content="${HOSTED_FORM_SUBMITTED_OUTCOME}">`,
59
+ '<meta name="robots" content="noindex">',
60
+ '<title>Submitted on the cardholder\'s device</title>',
61
+ '</head>',
62
+ '<body>',
63
+ `<p>The cardholder submitted this payment from their own device. Agentcard holds no evidence of the processor's answer: this is not a receipt. Do not resubmit this form. Confirm the order with the merchant${merchant ? ` (${escapeHtml(merchant)})` : ''}.</p>`,
64
+ `<script type="application/json" id="agentcard-checkout">${json}</script>`,
65
+ '</body>',
66
+ '</html>',
67
+ '',
68
+ ].join('\n');
69
+ return {
70
+ status: 200,
71
+ headers: {
72
+ 'content-type': 'text/html; charset=utf-8',
73
+ 'cache-control': 'no-store',
74
+ 'x-agentcard-checkout': HOSTED_FORM_SUBMITTED_OUTCOME,
75
+ },
76
+ body,
77
+ };
78
+ }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,10 @@
1
- export { VaultClient, CardEncryptedError, ApprovalTimeoutError, ApprovalDeclinedError } from './client.js';
2
- export type { PausedRequest, ReplayResponse, AuthorizeInput, VaultClientOptions } from './client.js';
1
+ export { VaultClient, CardEncryptedError, UnsupportedModeError, ApprovalTimeoutError, ApprovalDeclinedError, AmountMismatchError, IntentNotConfirmableError, ProcessorRefusedError, CheckoutApiError, redactUrl, SUPPORTED_MODES, } from './client.js';
2
+ export type { PausedRequest, ReplayResponse, TokenReplay, CseReplay, HostedFormReplay, AmountAuthority, AuthorizeInput, VaultClientOptions, } from './client.js';
3
3
  export { attachToCdp, attachToPlaywright } from './cdp.js';
4
4
  export type { CdpLike, AttachOptions } from './cdp.js';
5
- export { BUILTIN_REGISTRY, findRecognizer } from './registry.js';
6
- export type { Recognizer } from './registry.js';
5
+ export { substituteEncryptedFields, SubstitutionError } from './substitute.js';
6
+ export type { Substitutions } from './substitute.js';
7
+ export { hostedFormSubmittedPage, HOSTED_FORM_SUBMITTED_OUTCOME } from './hosted-form.js';
8
+ export type { HostedFormSubmittedPageInput, SyntheticPage } from './hosted-form.js';
9
+ export { BUILTIN_REGISTRY, cardUrlPatterns, findRecognizer } from './registry.js';
10
+ export type { Recognizer, CheckoutMode } from './registry.js';
package/dist/index.js CHANGED
@@ -1,3 +1,5 @@
1
- export { VaultClient, CardEncryptedError, ApprovalTimeoutError, ApprovalDeclinedError } from './client.js';
1
+ export { VaultClient, CardEncryptedError, UnsupportedModeError, ApprovalTimeoutError, ApprovalDeclinedError, AmountMismatchError, IntentNotConfirmableError, ProcessorRefusedError, CheckoutApiError, redactUrl, SUPPORTED_MODES, } from './client.js';
2
2
  export { attachToCdp, attachToPlaywright } from './cdp.js';
3
- export { BUILTIN_REGISTRY, findRecognizer } from './registry.js';
3
+ export { substituteEncryptedFields, SubstitutionError } from './substitute.js';
4
+ export { hostedFormSubmittedPage, HOSTED_FORM_SUBMITTED_OUTCOME } from './hosted-form.js';
5
+ export { BUILTIN_REGISTRY, cardUrlPatterns, findRecognizer } from './registry.js';