@402flow/sdk 0.1.0-alpha.3 → 0.1.0-alpha.30

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/index.js CHANGED
@@ -1,20 +1,79 @@
1
+ /**
2
+ * Core public client surface for @402flow/sdk.
3
+ *
4
+ * The SDK exposes two primary integration styles:
5
+ * 1. fetchPaid() for the fastest end-to-end paid request path
6
+ * 2. preparePaidRequest() plus executePreparedRequest() when the caller wants
7
+ * an explicit check/revise/execute loop
8
+ */
1
9
  import { createHash } from 'node:crypto';
2
10
  import { detectChallengeFromResponse, } from './challenge-detection.js';
3
- import { sdkPaymentDecisionRequestSchema, sdkPaymentDecisionResponseSchema, sdkReceiptResponseSchema, } from './contracts.js';
11
+ import { monetaryAmountToMinorUnits, paidRequestChallengeSchema, paidRequestHttpRequestSchema, sdkDelegatedExecutionResultSchema, sdkPaymentAuthorizationResponseSchema, sdkPaymentDecisionRequestSchema, sdkPaymentDecisionResponseSchema, sdkPaymentFinalizationRequestSchema, sdkReceiptResponseSchema, } from './contracts.js';
12
+ import { normalizeHeaders } from './http-utils.js';
13
+ import { sdkClientVersion, sdkClientVersionHeaderName, } from './version.js';
14
+ /**
15
+ * Thrown for all non-success paid outcomes. The original typed failure payload is
16
+ * preserved on details so callers can branch on kind without reparsing responses.
17
+ */
18
+ export class FetchPaidError extends Error {
19
+ details;
20
+ kind;
21
+ protocol;
22
+ response;
23
+ reason;
24
+ decision;
25
+ paidRequestId;
26
+ paymentAttemptId;
27
+ receiptId;
28
+ receipt;
29
+ policyReviewEventId;
30
+ constructor(details) {
31
+ super(`${details.kind}: ${details.reason}`);
32
+ this.name = 'FetchPaidError';
33
+ this.details = details;
34
+ this.kind = details.kind;
35
+ this.protocol = details.protocol;
36
+ this.response = details.response;
37
+ this.reason = details.reason;
38
+ this.decision = details.decision;
39
+ this.paidRequestId = 'paidRequestId' in details ? details.paidRequestId : undefined;
40
+ this.paymentAttemptId =
41
+ 'paymentAttemptId' in details ? details.paymentAttemptId : undefined;
42
+ this.receiptId = 'receiptId' in details ? details.receiptId : undefined;
43
+ this.receipt = 'receipt' in details ? details.receipt : undefined;
44
+ this.policyReviewEventId =
45
+ 'policyReviewEventId' in details ? details.policyReviewEventId : undefined;
46
+ Object.setPrototypeOf(this, new.target.prototype);
47
+ }
48
+ }
49
+ /** Type guard for callers that catch unknown errors around fetchPaid flows. */
50
+ export function isFetchPaidError(error) {
51
+ return error instanceof FetchPaidError;
52
+ }
4
53
  const defaultRuntimeTokenRefreshWindowMs = 30_000;
5
54
  function trimTrailingSlash(value) {
6
55
  return value.endsWith('/') ? value.slice(0, -1) : value;
7
56
  }
8
- function normalizeHeaders(headers) {
9
- if (!headers) {
10
- return undefined;
57
+ function isLoopbackHost(hostname) {
58
+ return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1';
59
+ }
60
+ function buildControlPlaneTransportErrorMessage(controlPlaneUrl, error) {
61
+ let localHttpsHint = '';
62
+ try {
63
+ const parsedUrl = new URL(controlPlaneUrl);
64
+ if (parsedUrl.protocol === 'https:' && isLoopbackHost(parsedUrl.hostname)) {
65
+ const httpUrl = new URL(controlPlaneUrl);
66
+ httpUrl.protocol = 'http:';
67
+ localHttpsHint = ` If this is a local control plane running without TLS, use ${httpUrl.origin} as the controlPlaneBaseUrl instead.`;
68
+ }
11
69
  }
12
- const normalizedHeaders = {};
13
- const headerMap = new Headers(headers);
14
- headerMap.forEach((value, key) => {
15
- normalizedHeaders[key] = value;
16
- });
17
- return normalizedHeaders;
70
+ catch {
71
+ // Ignore URL parsing failures and fall back to the generic transport message.
72
+ }
73
+ const originalMessage = error instanceof Error && error.message.trim().length > 0
74
+ ? error.message
75
+ : 'Unknown transport failure.';
76
+ return `Control plane request to ${controlPlaneUrl} failed.${localHttpsHint} Original error: ${originalMessage}`;
18
77
  }
19
78
  function createJsonResponse(status, payload) {
20
79
  return new Response(JSON.stringify(payload), {
@@ -30,7 +89,93 @@ function createMerchantResponse(merchantResponse) {
30
89
  headers: merchantResponse.headers,
31
90
  });
32
91
  }
33
- function getReplayableRequestBody(body) {
92
+ function createRawResponse(status, body, headers) {
93
+ return new Response(body, {
94
+ status,
95
+ ...(headers ? { headers } : {}),
96
+ });
97
+ }
98
+ function isRecord(value) {
99
+ return typeof value === 'object' && value !== null;
100
+ }
101
+ function getControlPlaneErrorMessage(body, fallback) {
102
+ if (!isRecord(body)) {
103
+ return fallback;
104
+ }
105
+ const message = typeof body.message === 'string' && body.message.length > 0
106
+ ? body.message
107
+ : fallback;
108
+ const issues = body.issues;
109
+ if (!isRecord(issues)) {
110
+ return message;
111
+ }
112
+ const details = [];
113
+ const formErrors = issues.formErrors;
114
+ if (Array.isArray(formErrors)) {
115
+ for (const entry of formErrors) {
116
+ if (typeof entry === 'string' && entry.length > 0) {
117
+ details.push(entry);
118
+ }
119
+ }
120
+ }
121
+ const fieldErrors = issues.fieldErrors;
122
+ if (isRecord(fieldErrors)) {
123
+ for (const [field, value] of Object.entries(fieldErrors)) {
124
+ if (!Array.isArray(value)) {
125
+ continue;
126
+ }
127
+ const fieldMessages = value.filter((entry) => typeof entry === 'string' && entry.length > 0);
128
+ if (fieldMessages.length > 0) {
129
+ details.push(`${field}: ${fieldMessages.join(', ')}`);
130
+ }
131
+ }
132
+ }
133
+ return details.length > 0 ? `${message} ${details.join(' ')}` : message;
134
+ }
135
+ async function readControlPlaneError(response, fallback) {
136
+ const responseBody = await response.text();
137
+ const parsedBody = tryParseJson(responseBody);
138
+ return {
139
+ responseBody,
140
+ parsedBody,
141
+ message: getControlPlaneErrorMessage(parsedBody, fallback),
142
+ };
143
+ }
144
+ function tryParseJson(value) {
145
+ if (value.length === 0) {
146
+ return undefined;
147
+ }
148
+ try {
149
+ return JSON.parse(value);
150
+ }
151
+ catch {
152
+ return undefined;
153
+ }
154
+ }
155
+ function describeRequestBodyType(body) {
156
+ if (body === undefined || body === null) {
157
+ return 'empty';
158
+ }
159
+ if (typeof body === 'string') {
160
+ return 'string';
161
+ }
162
+ if (body instanceof URLSearchParams) {
163
+ return 'URLSearchParams';
164
+ }
165
+ if (typeof body === 'object' && 'constructor' in body) {
166
+ const constructorName = body.constructor?.name;
167
+ if (typeof constructorName === 'string' && constructorName.length > 0) {
168
+ return constructorName;
169
+ }
170
+ }
171
+ return typeof body;
172
+ }
173
+ /** Returns true when a request body can be replayed exactly across paid flows. */
174
+ export function isReplayableRequestBody(body) {
175
+ return typeof body === 'string' || body instanceof URLSearchParams;
176
+ }
177
+ /** Serialize a paid-flow request body into the exact replayable wire representation. */
178
+ export function toReplayableRequestBody(body) {
34
179
  if (body === undefined || body === null) {
35
180
  return undefined;
36
181
  }
@@ -40,7 +185,30 @@ function getReplayableRequestBody(body) {
40
185
  if (body instanceof URLSearchParams) {
41
186
  return body.toString();
42
187
  }
43
- throw new Error('Paid requests currently support replayable string and URLSearchParams bodies when routed through the control plane.');
188
+ throw new Error(`Paid requests require replayable string or URLSearchParams bodies. Received ${describeRequestBodyType(body)}. Convert JSON payloads with createJsonRequestBody(...) or form payloads with createFormUrlEncodedBody(...).`);
189
+ }
190
+ /** Helper for callers that want an explicit JSON-string body for paid flows. */
191
+ export function createJsonRequestBody(payload) {
192
+ return JSON.stringify(payload);
193
+ }
194
+ /** Helper for callers that want a replayable form body for paid flows. */
195
+ export function createFormUrlEncodedBody(values) {
196
+ const params = new URLSearchParams();
197
+ for (const [key, rawValue] of Object.entries(values)) {
198
+ if (Array.isArray(rawValue)) {
199
+ for (const entry of rawValue) {
200
+ params.append(key, String(entry));
201
+ }
202
+ continue;
203
+ }
204
+ params.append(key, String(rawValue));
205
+ }
206
+ return params;
207
+ }
208
+ // Preparation and paid execution may need to replay the exact request body, so
209
+ // only body types that can be losslessly serialized are accepted here.
210
+ function getReplayableRequestBody(body) {
211
+ return toReplayableRequestBody(body);
44
212
  }
45
213
  function hashRequestBody(body) {
46
214
  if (!body) {
@@ -65,9 +233,687 @@ function parseRuntimeTokenResponse(payload) {
65
233
  expiresAt,
66
234
  };
67
235
  }
236
+ function createPreparedHttpRequest(input, init) {
237
+ const requestBody = getReplayableRequestBody(init.body);
238
+ return paidRequestHttpRequestSchema.parse({
239
+ url: input,
240
+ method: (init.method ?? 'GET').toUpperCase(),
241
+ headers: normalizeHeaders(init.headers),
242
+ body: requestBody,
243
+ bodyHash: hashRequestBody(requestBody),
244
+ });
245
+ }
246
+ function createPreparationAttribution(source, authority, note) {
247
+ return {
248
+ source,
249
+ authority,
250
+ ...(note ? { note } : {}),
251
+ };
252
+ }
253
+ function readExternalMetadata(options) {
254
+ return options.externalMetadata;
255
+ }
256
+ function readSchemaType(value) {
257
+ if (typeof value === 'string' && value.length > 0) {
258
+ return value;
259
+ }
260
+ if (Array.isArray(value)) {
261
+ const firstString = value.find((entry) => typeof entry === 'string' && entry.length > 0);
262
+ if (firstString) {
263
+ return firstString;
264
+ }
265
+ }
266
+ return undefined;
267
+ }
268
+ function readMerchantFieldsFromObjectSchema(objectSchema) {
269
+ const properties = isRecord(objectSchema.properties)
270
+ ? objectSchema.properties
271
+ : undefined;
272
+ const required = Array.isArray(objectSchema.required)
273
+ ? new Set(objectSchema.required.filter((entry) => typeof entry === 'string' && entry.length > 0))
274
+ : new Set();
275
+ const fields = new Map();
276
+ for (const [name, schema] of Object.entries(properties ?? {})) {
277
+ const propertySchema = isRecord(schema) ? schema : {};
278
+ fields.set(name.toLowerCase(), {
279
+ name,
280
+ ...(readSchemaType(propertySchema.type) ? { type: readSchemaType(propertySchema.type) } : {}),
281
+ ...(readStringValue(propertySchema.description)
282
+ ? { description: readStringValue(propertySchema.description) }
283
+ : {}),
284
+ ...(required.has(name) ? { required: true } : {}),
285
+ });
286
+ }
287
+ for (const fieldName of required) {
288
+ if (!fields.has(fieldName.toLowerCase())) {
289
+ fields.set(fieldName.toLowerCase(), {
290
+ name: fieldName,
291
+ required: true,
292
+ });
293
+ }
294
+ }
295
+ return Array.from(fields.values());
296
+ }
297
+ function inferFieldTypeFromValue(value) {
298
+ if (Array.isArray(value)) {
299
+ return 'array';
300
+ }
301
+ if (value === null) {
302
+ return undefined;
303
+ }
304
+ switch (typeof value) {
305
+ case 'string':
306
+ return 'string';
307
+ case 'number':
308
+ return Number.isInteger(value) ? 'integer' : 'number';
309
+ case 'boolean':
310
+ return 'boolean';
311
+ case 'object':
312
+ return 'object';
313
+ default:
314
+ return undefined;
315
+ }
316
+ }
317
+ function inferFieldsFromQueryParamExample(queryParams) {
318
+ return Object.entries(queryParams).map(([name, value]) => ({
319
+ name,
320
+ ...(inferFieldTypeFromValue(value) ? { type: inferFieldTypeFromValue(value) } : {}),
321
+ required: true,
322
+ }));
323
+ }
324
+ async function resolveJsonSchemaRef(schema, fetchImpl) {
325
+ const ref = readStringValue(schema.$ref);
326
+ if (!ref) {
327
+ return schema;
328
+ }
329
+ try {
330
+ const url = new URL(ref);
331
+ if (!['http:', 'https:'].includes(url.protocol)) {
332
+ return undefined;
333
+ }
334
+ const response = await fetchImpl(url.toString());
335
+ if (!response.ok) {
336
+ return undefined;
337
+ }
338
+ const parsed = await response.json();
339
+ return isRecord(parsed) ? parsed : undefined;
340
+ }
341
+ catch {
342
+ return undefined;
343
+ }
344
+ }
345
+ async function readMerchantPreparationMetadataFromInputSchema(inputSchema, requestBodyExample, queryParamExample, fetchImpl) {
346
+ const bodyType = readStringValue(inputSchema.bodyType)
347
+ ?? (isRecord(inputSchema.properties)
348
+ && isRecord(inputSchema.properties.bodyType)
349
+ ? readStringValue(inputSchema.properties.bodyType.const)
350
+ : undefined);
351
+ const bodySchema = isRecord(inputSchema.body)
352
+ ? inputSchema.body
353
+ : isRecord(inputSchema.properties)
354
+ && isRecord(inputSchema.properties.body)
355
+ ? inputSchema.properties.body
356
+ : undefined;
357
+ const queryParamSchema = isRecord(inputSchema.queryParams)
358
+ ? inputSchema.queryParams
359
+ : isRecord(inputSchema.properties)
360
+ && isRecord(inputSchema.properties.queryParams)
361
+ ? inputSchema.properties.queryParams
362
+ : undefined;
363
+ const requestBodyFields = bodySchema
364
+ ? readMerchantFieldsFromObjectSchema(bodySchema)
365
+ : undefined;
366
+ const resolvedQueryParamSchema = queryParamSchema
367
+ ? await resolveJsonSchemaRef(queryParamSchema, fetchImpl)
368
+ : undefined;
369
+ const requestQueryParams = resolvedQueryParamSchema
370
+ ? readMerchantFieldsFromObjectSchema(resolvedQueryParamSchema)
371
+ : queryParamExample
372
+ ? inferFieldsFromQueryParamExample(queryParamExample)
373
+ : undefined;
374
+ const hasBodyFields = requestBodyFields && requestBodyFields.length > 0;
375
+ const hasQueryFields = requestQueryParams && requestQueryParams.length > 0;
376
+ if (!bodyType && !requestBodyExample && !hasBodyFields && !hasQueryFields) {
377
+ return undefined;
378
+ }
379
+ return {
380
+ ...(bodyType ? { requestBodyType: bodyType } : {}),
381
+ ...(requestBodyExample ? { requestBodyExample } : {}),
382
+ ...(hasBodyFields
383
+ ? { requestBodyFields }
384
+ : {}),
385
+ ...(hasQueryFields
386
+ ? { requestQueryParams }
387
+ : {}),
388
+ notes: ['Request hints derived from merchant challenge metadata.'],
389
+ };
390
+ }
391
+ async function readMerchantPreparationMetadata(challenge, fetchImpl) {
392
+ if (!challenge) {
393
+ return undefined;
394
+ }
395
+ const paymentRequiredPayload = tryParsePaymentRequiredHeader(challenge.headers['payment-required']);
396
+ const payload = unwrapChallengePayload(paymentRequiredPayload ?? challenge.body);
397
+ if (!isRecord(payload)) {
398
+ return undefined;
399
+ }
400
+ const accepts = Array.isArray(payload.accepts) ? payload.accepts : [];
401
+ for (const accept of accepts) {
402
+ if (!isRecord(accept) || !isRecord(accept.extra) || !isRecord(accept.extra.outputSchema)) {
403
+ continue;
404
+ }
405
+ const outputSchema = accept.extra.outputSchema;
406
+ const metadata = isRecord(outputSchema.input)
407
+ ? await readMerchantPreparationMetadataFromInputSchema(outputSchema.input, undefined, undefined, fetchImpl)
408
+ : undefined;
409
+ if (metadata) {
410
+ return metadata;
411
+ }
412
+ }
413
+ const bazaar = isRecord(payload.extensions) && isRecord(payload.extensions.bazaar)
414
+ ? payload.extensions.bazaar
415
+ : undefined;
416
+ const bazaarInfoInput = isRecord(bazaar?.info) && isRecord(bazaar.info.input)
417
+ ? bazaar.info.input
418
+ : undefined;
419
+ const bazaarRequestBodyExample = isRecord(bazaarInfoInput?.body)
420
+ ? JSON.stringify(bazaarInfoInput.body)
421
+ : undefined;
422
+ const bazaarQueryParamExample = isRecord(bazaarInfoInput?.queryParams)
423
+ ? bazaarInfoInput.queryParams
424
+ : undefined;
425
+ const bazaarInputSchema = isRecord(bazaar?.schema)
426
+ && isRecord(bazaar.schema.properties)
427
+ && isRecord(bazaar.schema.properties.input)
428
+ ? bazaar.schema.properties.input
429
+ : undefined;
430
+ if (bazaarInputSchema) {
431
+ return readMerchantPreparationMetadataFromInputSchema(bazaarInputSchema, bazaarRequestBodyExample, bazaarQueryParamExample, fetchImpl);
432
+ }
433
+ return undefined;
434
+ }
435
+ function pickPreparedHintValue(options, merchantChallengeMetadata, key) {
436
+ if (merchantChallengeMetadata?.[key]) {
437
+ return {
438
+ value: merchantChallengeMetadata[key],
439
+ attribution: createPreparationAttribution('merchant_challenge', 'authoritative'),
440
+ };
441
+ }
442
+ const externalMetadata = readExternalMetadata(options);
443
+ if (externalMetadata?.[key]) {
444
+ return {
445
+ value: externalMetadata[key],
446
+ attribution: createPreparationAttribution('external_metadata', 'advisory'),
447
+ };
448
+ }
449
+ return undefined;
450
+ }
451
+ function mergePreparedHintFields(options, merchantChallengeMetadata, key) {
452
+ const fields = new Map();
453
+ for (const field of merchantChallengeMetadata?.[key] ?? []) {
454
+ fields.set(field.name.toLowerCase(), {
455
+ ...field,
456
+ attribution: createPreparationAttribution('merchant_challenge', 'authoritative'),
457
+ });
458
+ }
459
+ for (const field of readExternalMetadata(options)?.[key] ?? []) {
460
+ const normalizedName = field.name.toLowerCase();
461
+ if (fields.has(normalizedName)) {
462
+ continue;
463
+ }
464
+ fields.set(normalizedName, {
465
+ ...field,
466
+ attribution: createPreparationAttribution('external_metadata', 'advisory'),
467
+ });
468
+ }
469
+ return Array.from(fields.values());
470
+ }
471
+ async function buildPreparedRequestHints(options, fetchImpl, challenge) {
472
+ // Hint assembly merges optional caller metadata with merchant-authoritative
473
+ // challenge metadata while preserving attribution for every returned field.
474
+ const merchantChallengeMetadata = await readMerchantPreparationMetadata(challenge, fetchImpl);
475
+ const externalMetadata = readExternalMetadata(options);
476
+ return {
477
+ ...(pickPreparedHintValue(options, merchantChallengeMetadata, 'description')
478
+ ? {
479
+ description: pickPreparedHintValue(options, merchantChallengeMetadata, 'description'),
480
+ }
481
+ : {}),
482
+ ...(pickPreparedHintValue(options, merchantChallengeMetadata, 'requestBodyType')
483
+ ? {
484
+ requestBodyType: pickPreparedHintValue(options, merchantChallengeMetadata, 'requestBodyType'),
485
+ }
486
+ : {}),
487
+ ...(pickPreparedHintValue(options, merchantChallengeMetadata, 'requestBodyExample')
488
+ ? {
489
+ requestBodyExample: pickPreparedHintValue(options, merchantChallengeMetadata, 'requestBodyExample'),
490
+ }
491
+ : {}),
492
+ requestBodyFields: mergePreparedHintFields(options, merchantChallengeMetadata, 'requestBodyFields'),
493
+ requestQueryParams: mergePreparedHintFields(options, merchantChallengeMetadata, 'requestQueryParams'),
494
+ requestPathParams: mergePreparedHintFields(options, merchantChallengeMetadata, 'requestPathParams'),
495
+ notes: [
496
+ ...(merchantChallengeMetadata?.notes ?? []).map((value) => ({
497
+ value,
498
+ attribution: createPreparationAttribution('merchant_challenge', 'authoritative'),
499
+ })),
500
+ ...(externalMetadata?.notes ?? []).map((value) => ({
501
+ value,
502
+ attribution: createPreparationAttribution('external_metadata', 'advisory'),
503
+ })),
504
+ ],
505
+ };
506
+ }
507
+ function isJsonContentType(value) {
508
+ return value?.toLowerCase().includes('application/json') ?? false;
509
+ }
510
+ function matchesExpectedFieldType(value, expectedType) {
511
+ switch (expectedType?.toLowerCase()) {
512
+ case 'string':
513
+ return typeof value === 'string';
514
+ case 'number':
515
+ return typeof value === 'number' && Number.isFinite(value);
516
+ case 'integer':
517
+ case 'int':
518
+ return typeof value === 'number' && Number.isInteger(value);
519
+ case 'boolean':
520
+ return typeof value === 'boolean';
521
+ case 'object':
522
+ return isRecord(value);
523
+ case 'array':
524
+ return Array.isArray(value);
525
+ default:
526
+ return true;
527
+ }
528
+ }
529
+ function buildPreparedValidationIssues(request, hints) {
530
+ // Validation is intentionally narrow: it checks only request-shape issues the
531
+ // SDK can defend from available hints, not task-specific semantic correctness.
532
+ const issues = [];
533
+ const contentType = request.headers?.['content-type'];
534
+ const requestBodyType = hints.requestBodyType?.value.toLowerCase();
535
+ const requiredBodyFields = hints.requestBodyFields.filter((field) => field.required);
536
+ if (requestBodyType === 'json'
537
+ && request.body !== undefined
538
+ && !isJsonContentType(contentType)) {
539
+ issues.push({
540
+ location: 'headers',
541
+ field: 'content-type',
542
+ code: 'unsupported_content_type',
543
+ message: 'Request body is expected to be JSON but content-type is not application/json.',
544
+ source: hints.requestBodyType?.attribution.source ?? 'external_metadata',
545
+ blocking: true,
546
+ severity: 'error',
547
+ suggestedFix: 'Send the request with content-type: application/json.',
548
+ });
549
+ }
550
+ if (hints.requestBodyFields.length > 0) {
551
+ if (request.body === undefined) {
552
+ for (const field of requiredBodyFields) {
553
+ issues.push({
554
+ location: 'body',
555
+ field: field.name,
556
+ code: 'missing_required_field',
557
+ message: `Required request body field "${field.name}" is missing.`,
558
+ source: field.attribution.source,
559
+ blocking: true,
560
+ severity: 'error',
561
+ suggestedFix: `Add the required body field "${field.name}" before execution.`,
562
+ });
563
+ }
564
+ }
565
+ else {
566
+ const parsedBody = tryParseJson(request.body);
567
+ if (parsedBody === undefined) {
568
+ issues.push({
569
+ location: 'body',
570
+ field: 'body',
571
+ code: 'malformed_candidate_value',
572
+ message: 'Request body must be valid JSON to satisfy the declared body fields.',
573
+ source: hints.requestBodyType?.attribution.source
574
+ ?? hints.requestBodyFields[0]?.attribution.source
575
+ ?? 'external_metadata',
576
+ blocking: true,
577
+ severity: 'error',
578
+ suggestedFix: 'Send a valid JSON object body that satisfies the declared fields.',
579
+ });
580
+ }
581
+ else if (!isRecord(parsedBody)) {
582
+ issues.push({
583
+ location: 'body',
584
+ field: 'body',
585
+ code: 'request_shape_conflicts_with_hint',
586
+ message: 'Request body must be a JSON object to satisfy the declared body fields.',
587
+ source: hints.requestBodyFields[0]?.attribution.source
588
+ ?? hints.requestBodyType?.attribution.source
589
+ ?? 'external_metadata',
590
+ blocking: true,
591
+ severity: 'error',
592
+ suggestedFix: 'Send a JSON object body whose keys match the declared fields.',
593
+ });
594
+ }
595
+ else {
596
+ for (const field of requiredBodyFields) {
597
+ if (!(field.name in parsedBody)) {
598
+ issues.push({
599
+ location: 'body',
600
+ field: field.name,
601
+ code: 'missing_required_field',
602
+ message: `Required request body field "${field.name}" is missing.`,
603
+ source: field.attribution.source,
604
+ blocking: true,
605
+ severity: 'error',
606
+ suggestedFix: `Add the required body field "${field.name}" before execution.`,
607
+ });
608
+ }
609
+ }
610
+ for (const field of hints.requestBodyFields) {
611
+ if (!(field.name in parsedBody)) {
612
+ continue;
613
+ }
614
+ if (!matchesExpectedFieldType(parsedBody[field.name], field.type)) {
615
+ issues.push({
616
+ location: 'body',
617
+ field: field.name,
618
+ code: 'malformed_candidate_value',
619
+ message: `Request body field "${field.name}" does not match the expected type${field.type ? ` "${field.type}"` : ''}.`,
620
+ source: field.attribution.source,
621
+ blocking: true,
622
+ severity: 'error',
623
+ suggestedFix: `Set "${field.name}" to a value compatible with the declared type${field.type ? ` "${field.type}"` : ''}.`,
624
+ });
625
+ }
626
+ }
627
+ }
628
+ }
629
+ }
630
+ const url = new URL(request.url);
631
+ for (const field of hints.requestQueryParams.filter((entry) => entry.required)) {
632
+ if (!url.searchParams.has(field.name)) {
633
+ issues.push({
634
+ location: 'query',
635
+ field: field.name,
636
+ code: 'missing_required_query_param',
637
+ message: `Required query parameter "${field.name}" is missing.`,
638
+ source: field.attribution.source,
639
+ blocking: true,
640
+ severity: 'error',
641
+ suggestedFix: `Add the required query parameter "${field.name}" before execution.`,
642
+ });
643
+ }
644
+ }
645
+ for (const field of hints.requestPathParams.filter((entry) => entry.required)) {
646
+ if (url.pathname.includes(`{${field.name}}`)
647
+ || url.pathname.includes(`:${field.name}`)) {
648
+ issues.push({
649
+ location: 'path',
650
+ field: field.name,
651
+ code: 'missing_required_path_param',
652
+ message: `Required path parameter "${field.name}" is still unresolved in the request URL.`,
653
+ source: field.attribution.source,
654
+ blocking: true,
655
+ severity: 'error',
656
+ suggestedFix: `Replace the unresolved path placeholder for "${field.name}" before execution.`,
657
+ });
658
+ }
659
+ }
660
+ const seen = new Set();
661
+ return issues.filter((issue) => {
662
+ const key = [
663
+ issue.location,
664
+ issue.field.toLowerCase(),
665
+ issue.code,
666
+ issue.source,
667
+ ].join(':');
668
+ if (seen.has(key)) {
669
+ return false;
670
+ }
671
+ seen.add(key);
672
+ return true;
673
+ });
674
+ }
675
+ function derivePreparedNextAction(kind, validationIssues) {
676
+ if (kind === 'passthrough') {
677
+ return 'treat_as_passthrough';
678
+ }
679
+ if (validationIssues.some((issue) => issue.blocking)) {
680
+ return 'revise_request';
681
+ }
682
+ return 'execute';
683
+ }
684
+ function readStringValue(value) {
685
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
686
+ }
687
+ function readIntegerValue(value) {
688
+ if (typeof value === 'number' && Number.isInteger(value)) {
689
+ return value;
690
+ }
691
+ if (typeof value === 'string' && /^\d+$/.test(value)) {
692
+ return Number.parseInt(value, 10);
693
+ }
694
+ return undefined;
695
+ }
696
+ function readPrecisionFromAmount(amount) {
697
+ if (!amount) {
698
+ return undefined;
699
+ }
700
+ const [, fractionalPart = ''] = amount.split('.');
701
+ return fractionalPart.length;
702
+ }
703
+ function formatMinorUnitsAsAmount(amountMinor, precision) {
704
+ if (precision === 0) {
705
+ return amountMinor;
706
+ }
707
+ const normalizedMinor = amountMinor.replace(/^0+(?=\d)/, '') || '0';
708
+ const paddedMinor = normalizedMinor.padStart(precision + 1, '0');
709
+ const wholePart = paddedMinor.slice(0, -precision) || '0';
710
+ const fractionalPart = paddedMinor.slice(-precision);
711
+ return `${wholePart}.${fractionalPart}`;
712
+ }
713
+ function tryParsePaymentRequiredHeader(value) {
714
+ if (!value) {
715
+ return undefined;
716
+ }
717
+ try {
718
+ return JSON.parse(Buffer.from(value, 'base64').toString('utf8'));
719
+ }
720
+ catch {
721
+ // Fall through.
722
+ }
723
+ return tryParseJson(value);
724
+ }
725
+ function unwrapChallengePayload(payload) {
726
+ if (!isRecord(payload)) {
727
+ return undefined;
728
+ }
729
+ if (isRecord(payload.challenge)) {
730
+ return payload.challenge;
731
+ }
732
+ return payload;
733
+ }
734
+ function parseAuthenticateHeaderParameters(header) {
735
+ if (!header) {
736
+ return new Map();
737
+ }
738
+ const matches = header.matchAll(/([a-zA-Z0-9_-]+)="([^"]+)"/g);
739
+ const parameters = new Map();
740
+ for (const match of matches) {
741
+ const [, key, value] = match;
742
+ if (key && value) {
743
+ parameters.set(key.toLowerCase(), value);
744
+ }
745
+ }
746
+ return parameters;
747
+ }
748
+ function buildPreparedPaymentRequirement(challenge) {
749
+ // Payment terms are normalized into one portable structure so callers do not
750
+ // need protocol-specific parsing logic in their agent or application layer.
751
+ const provenance = createPreparationAttribution('merchant_challenge', 'authoritative');
752
+ const paymentRequiredPayload = tryParsePaymentRequiredHeader(challenge.headers['payment-required']);
753
+ const payload = unwrapChallengePayload(paymentRequiredPayload ?? challenge.body);
754
+ if (isRecord(payload)) {
755
+ const resource = isRecord(payload.resource) ? payload.resource : undefined;
756
+ const accepts = Array.isArray(payload.accepts) ? payload.accepts : undefined;
757
+ const primaryAccept = accepts?.find((candidate) => isRecord(candidate));
758
+ if (primaryAccept && isRecord(primaryAccept)) {
759
+ const amountMinor = readStringValue(primaryAccept.amount)
760
+ ?? readStringValue(primaryAccept.maxAmountRequired);
761
+ const precision = readIntegerValue(primaryAccept.precision)
762
+ ?? (isRecord(primaryAccept.extra)
763
+ ? readIntegerValue(primaryAccept.extra.precision)
764
+ ?? readIntegerValue(primaryAccept.extra.decimals)
765
+ : undefined);
766
+ const amount = amountMinor && precision !== undefined
767
+ ? formatMinorUnitsAsAmount(amountMinor, precision)
768
+ : undefined;
769
+ return {
770
+ protocol: challenge.protocol,
771
+ ...(readStringValue(resource?.description)
772
+ ? { description: readStringValue(resource?.description) }
773
+ : {}),
774
+ ...(readStringValue(primaryAccept.asset)
775
+ ? { asset: readStringValue(primaryAccept.asset) }
776
+ : {}),
777
+ ...(readStringValue(primaryAccept.network)
778
+ ? { network: readStringValue(primaryAccept.network) }
779
+ : {}),
780
+ ...(readStringValue(primaryAccept.payTo)
781
+ ? { payee: readStringValue(primaryAccept.payTo) }
782
+ : {}),
783
+ ...(readStringValue(primaryAccept.amount)
784
+ ? { amountType: 'exact' }
785
+ : readStringValue(primaryAccept.maxAmountRequired)
786
+ ? { amountType: 'max' }
787
+ : {}),
788
+ ...(amount ? { amount } : {}),
789
+ ...(amountMinor ? { amountMinor } : {}),
790
+ ...(precision !== undefined ? { precision } : {}),
791
+ provenance,
792
+ };
793
+ }
794
+ }
795
+ const explicitAmount = readStringValue(challenge.headers['x-payment-amount']);
796
+ const explicitPrecision = readIntegerValue(challenge.headers['x-payment-precision'])
797
+ ?? readPrecisionFromAmount(explicitAmount);
798
+ if (explicitAmount || challenge.headers['www-authenticate']) {
799
+ const authenticateParameters = parseAuthenticateHeaderParameters(challenge.headers['www-authenticate']);
800
+ const amount = explicitAmount ?? authenticateParameters.get('amount');
801
+ const precision = explicitPrecision ?? readPrecisionFromAmount(amount);
802
+ const asset = readStringValue(challenge.headers['x-payment-asset'])
803
+ ?? authenticateParameters.get('asset');
804
+ const payee = readStringValue(challenge.headers['x-payment-payee'])
805
+ ?? authenticateParameters.get('payee');
806
+ const network = readStringValue(challenge.headers['x-payment-network'])
807
+ ?? authenticateParameters.get('network');
808
+ const amountMinor = amount && precision !== undefined
809
+ ? monetaryAmountToMinorUnits(amount, precision)
810
+ : undefined;
811
+ return {
812
+ protocol: challenge.protocol,
813
+ ...(asset ? { asset } : {}),
814
+ ...(network ? { network } : {}),
815
+ ...(payee ? { payee } : {}),
816
+ ...(amount ? { amount } : {}),
817
+ ...(amountMinor ? { amountMinor } : {}),
818
+ ...(precision !== undefined ? { precision } : {}),
819
+ ...(amount ? { amountType: 'exact' } : {}),
820
+ provenance,
821
+ };
822
+ }
823
+ return {
824
+ protocol: challenge.protocol,
825
+ provenance,
826
+ };
827
+ }
828
+ function buildPreparedChallengeAccept(candidate) {
829
+ const maxTimeoutSeconds = readIntegerValue(candidate.maxTimeoutSeconds);
830
+ return {
831
+ ...(readStringValue(candidate.scheme)
832
+ ? { scheme: readStringValue(candidate.scheme) }
833
+ : {}),
834
+ ...(readStringValue(candidate.network)
835
+ ? { network: readStringValue(candidate.network) }
836
+ : {}),
837
+ ...(readStringValue(candidate.amount)
838
+ ? { amount: readStringValue(candidate.amount) }
839
+ : {}),
840
+ ...(readStringValue(candidate.maxAmountRequired)
841
+ ? { maxAmountRequired: readStringValue(candidate.maxAmountRequired) }
842
+ : {}),
843
+ ...(readStringValue(candidate.asset)
844
+ ? { asset: readStringValue(candidate.asset) }
845
+ : {}),
846
+ ...(readStringValue(candidate.payTo)
847
+ ? { payTo: readStringValue(candidate.payTo) }
848
+ : {}),
849
+ ...(maxTimeoutSeconds && maxTimeoutSeconds > 0
850
+ ? { maxTimeoutSeconds }
851
+ : {}),
852
+ ...(readStringValue(candidate.resource)
853
+ ? { resource: readStringValue(candidate.resource) }
854
+ : {}),
855
+ ...(readStringValue(candidate.description)
856
+ ? { description: readStringValue(candidate.description) }
857
+ : {}),
858
+ ...(readStringValue(candidate.mimeType)
859
+ ? { mimeType: readStringValue(candidate.mimeType) }
860
+ : {}),
861
+ ...(candidate.outputSchema !== undefined
862
+ ? { outputSchema: candidate.outputSchema }
863
+ : {}),
864
+ ...(isRecord(candidate.extra)
865
+ ? { extra: candidate.extra }
866
+ : {}),
867
+ };
868
+ }
869
+ function buildPreparedChallengeDetails(challenge) {
870
+ if (challenge.protocol !== 'x402') {
871
+ return undefined;
872
+ }
873
+ const paymentRequiredPayload = tryParsePaymentRequiredHeader(challenge.headers['payment-required']);
874
+ const payload = unwrapChallengePayload(paymentRequiredPayload ?? challenge.body);
875
+ if (!isRecord(payload)) {
876
+ return undefined;
877
+ }
878
+ const resource = isRecord(payload.resource) ? payload.resource : undefined;
879
+ const accepts = Array.isArray(payload.accepts)
880
+ ? payload.accepts.filter((candidate) => isRecord(candidate))
881
+ : [];
882
+ const x402Version = readIntegerValue(payload.x402Version);
883
+ return {
884
+ ...(x402Version !== undefined ? { x402Version } : {}),
885
+ ...(readStringValue(payload.error)
886
+ ? { error: readStringValue(payload.error) }
887
+ : {}),
888
+ ...(resource && readStringValue(resource.url)
889
+ ? {
890
+ resource: {
891
+ url: readStringValue(resource.url),
892
+ ...(readStringValue(resource.description)
893
+ ? { description: readStringValue(resource.description) }
894
+ : {}),
895
+ ...(readStringValue(resource.mimeType)
896
+ ? { mimeType: readStringValue(resource.mimeType) }
897
+ : {}),
898
+ },
899
+ }
900
+ : {}),
901
+ accepts: accepts.map((candidate) => buildPreparedChallengeAccept(candidate)),
902
+ ...(isRecord(payload.extensions)
903
+ ? { extensions: payload.extensions }
904
+ : {}),
905
+ };
906
+ }
907
+ /**
908
+ * Client bound to one organization/agent identity and one 402flow control plane.
909
+ *
910
+ * Most integrations either call fetchPaid() directly or use the explicit
911
+ * preparePaidRequest() -> executePreparedRequest() flow.
912
+ */
68
913
  export class AgentPayClient {
69
914
  controlPlaneBaseUrl;
70
915
  auth;
916
+ identity;
71
917
  fetchImpl;
72
918
  headers;
73
919
  cachedRuntimeToken;
@@ -75,11 +921,141 @@ export class AgentPayClient {
75
921
  constructor(options) {
76
922
  this.controlPlaneBaseUrl = trimTrailingSlash(options.controlPlaneBaseUrl);
77
923
  this.auth = options.auth;
924
+ this.identity = {
925
+ organization: options.organization,
926
+ agent: options.agent,
927
+ };
78
928
  this.fetchImpl = options.fetch ?? fetch;
79
929
  this.headers = options.headers;
80
930
  }
81
- async fetchPaid(input, init = {}, context, options) {
82
- let challenge = options.challenge;
931
+ /**
932
+ * Probe or reuse a merchant challenge and return a normalized preparation
933
+ * result the caller can inspect before paying.
934
+ */
935
+ async preparePaidRequest(input, init = {}, options = {}) {
936
+ const request = createPreparedHttpRequest(input, init);
937
+ if (options.challenge) {
938
+ const hints = await buildPreparedRequestHints(options, this.fetchImpl, options.challenge);
939
+ const validationIssues = buildPreparedValidationIssues(request, hints);
940
+ const paymentRequirement = buildPreparedPaymentRequirement(options.challenge);
941
+ const challengeDetails = buildPreparedChallengeDetails(options.challenge);
942
+ return {
943
+ kind: 'ready',
944
+ protocol: options.challenge.protocol,
945
+ request,
946
+ challenge: paidRequestChallengeSchema.parse(options.challenge),
947
+ ...(challengeDetails ? { challengeDetails } : {}),
948
+ ...(paymentRequirement
949
+ ? {
950
+ paymentRequirement,
951
+ }
952
+ : {}),
953
+ hints,
954
+ validationIssues,
955
+ nextAction: derivePreparedNextAction('ready', validationIssues),
956
+ };
957
+ }
958
+ const initialResponse = await this.fetchImpl(input, init);
959
+ const challenge = await detectChallengeFromResponse(initialResponse);
960
+ const probe = {
961
+ responseStatus: initialResponse.status,
962
+ confirmedAt: new Date().toISOString(),
963
+ };
964
+ const hints = await buildPreparedRequestHints(options, this.fetchImpl, challenge);
965
+ const validationIssues = buildPreparedValidationIssues(request, hints);
966
+ if (!challenge) {
967
+ return {
968
+ kind: 'passthrough',
969
+ protocol: 'none',
970
+ request,
971
+ hints,
972
+ probe,
973
+ validationIssues,
974
+ nextAction: derivePreparedNextAction('passthrough', validationIssues),
975
+ };
976
+ }
977
+ const paymentRequirement = buildPreparedPaymentRequirement(challenge);
978
+ const challengeDetails = buildPreparedChallengeDetails(challenge);
979
+ return {
980
+ kind: 'ready',
981
+ protocol: challenge.protocol,
982
+ request,
983
+ challenge: paidRequestChallengeSchema.parse(challenge),
984
+ ...(challengeDetails ? { challengeDetails } : {}),
985
+ ...(paymentRequirement
986
+ ? { paymentRequirement }
987
+ : {}),
988
+ hints,
989
+ probe,
990
+ validationIssues,
991
+ nextAction: derivePreparedNextAction('ready', validationIssues),
992
+ };
993
+ }
994
+ /**
995
+ * Execute the exact request that was previously prepared, without re-probing
996
+ * the merchant first.
997
+ */
998
+ async executePreparedRequest(prepared, request = {}) {
999
+ const delegatedExecution = this.resolveDelegatedPreparedExecution(request);
1000
+ if (delegatedExecution) {
1001
+ const authorizationRequest = {
1002
+ ...delegatedExecution.request,
1003
+ challenge: prepared.challenge,
1004
+ };
1005
+ const decisionRequest = this.createDecisionRequest(prepared.request.url, {
1006
+ method: prepared.request.method,
1007
+ ...(prepared.request.headers ? { headers: prepared.request.headers } : {}),
1008
+ ...(prepared.request.body !== undefined
1009
+ ? { body: prepared.request.body }
1010
+ : {}),
1011
+ }, authorizationRequest, prepared.challenge);
1012
+ const authorization = await this.requestPaymentAuthorization(decisionRequest, prepared.challenge.protocol);
1013
+ if (authorization.outcome !== 'authorized') {
1014
+ return this.mapDecisionToPaidResponse(authorization, prepared.challenge.protocol);
1015
+ }
1016
+ const delegatedResult = await this.executeWithPreparedExecutor({
1017
+ prepared,
1018
+ delegatedExecution,
1019
+ authorization,
1020
+ });
1021
+ const finalizationRequest = sdkPaymentFinalizationRequestSchema.parse({
1022
+ paidRequestId: authorization.paidRequestId,
1023
+ paymentAttemptId: authorization.paymentAttemptId,
1024
+ result: delegatedResult,
1025
+ });
1026
+ let finalization;
1027
+ try {
1028
+ finalization = await this.requestPaymentFinalization(finalizationRequest, prepared.challenge.protocol);
1029
+ }
1030
+ catch (error) {
1031
+ if (error instanceof FetchPaidError) {
1032
+ throw error;
1033
+ }
1034
+ throw this.buildDelegatedFinalizationTransportLossError({
1035
+ protocol: prepared.challenge.protocol,
1036
+ authorization,
1037
+ delegatedResult,
1038
+ error,
1039
+ });
1040
+ }
1041
+ return this.mapDecisionToPaidResponse(finalization, prepared.challenge.protocol);
1042
+ }
1043
+ return this.fetchPaid(prepared.request.url, {
1044
+ method: prepared.request.method,
1045
+ ...(prepared.request.headers ? { headers: prepared.request.headers } : {}),
1046
+ ...(prepared.request.body !== undefined ? { body: prepared.request.body } : {}),
1047
+ }, {
1048
+ ...request,
1049
+ challenge: prepared.challenge,
1050
+ });
1051
+ }
1052
+ /**
1053
+ * Fast-path helper that probes when needed, asks the control plane for a paid
1054
+ * execution decision, and returns either passthrough or success. All non-success
1055
+ * paid outcomes are thrown as FetchPaidError.
1056
+ */
1057
+ async fetchPaid(input, init = {}, request) {
1058
+ let challenge = request.challenge;
83
1059
  if (!challenge) {
84
1060
  const initialResponse = await this.fetchImpl(input, init);
85
1061
  challenge = await detectChallengeFromResponse(initialResponse);
@@ -91,60 +1067,195 @@ export class AgentPayClient {
91
1067
  };
92
1068
  }
93
1069
  }
94
- const decisionRequest = await this.createDecisionRequest(input, init, context, options, challenge);
95
- const decision = await this.requestPaymentDecision(decisionRequest);
1070
+ const decisionRequest = this.createDecisionRequest(input, init, request, challenge);
1071
+ const decision = await this.requestPaymentDecision(decisionRequest, challenge.protocol);
96
1072
  return this.mapDecisionToPaidResponse(decision, challenge.protocol);
97
1073
  }
1074
+ /** Lookup a durable receipt by id through the control plane. */
98
1075
  async lookupReceipt(receiptId) {
99
1076
  const response = await this.controlPlaneFetch(`/api/sdk/receipts/${receiptId}`, {
100
1077
  method: 'GET',
101
1078
  }, await this.getRuntimeAuthorizationHeader());
102
1079
  if (!response.ok) {
103
- throw new Error(`Receipt lookup failed with status ${response.status}.`);
1080
+ const error = await readControlPlaneError(response, `Receipt lookup failed with status ${response.status}.`);
1081
+ throw new Error(error.message);
104
1082
  }
105
1083
  return sdkReceiptResponseSchema.parse(await response.json());
106
1084
  }
107
- async createDecisionRequest(input, init, context, options, challenge) {
108
- const requestBody = getReplayableRequestBody(init.body);
1085
+ createDecisionRequest(input, init, request, challenge) {
1086
+ const { challenge: _challenge, idempotencyKey, ...requestContext } = request;
109
1087
  return sdkPaymentDecisionRequestSchema.parse({
110
- context,
111
- target: options.target,
112
- request: {
113
- url: input,
114
- method: (init.method ?? 'GET').toUpperCase(),
115
- headers: normalizeHeaders(init.headers),
116
- body: requestBody,
117
- bodyHash: hashRequestBody(requestBody),
1088
+ context: {
1089
+ ...requestContext,
1090
+ ...this.identity,
118
1091
  },
1092
+ request: createPreparedHttpRequest(input, init),
119
1093
  challenge: {
120
1094
  protocol: challenge.protocol,
121
- money: challenge.money,
122
- raw: challenge.raw,
123
- ...(challenge.payee ? { payee: challenge.payee } : {}),
1095
+ headers: challenge.headers,
1096
+ ...(challenge.body !== undefined ? { body: challenge.body } : {}),
124
1097
  },
125
- idempotencyKey: options.idempotencyKey,
1098
+ idempotencyKey,
126
1099
  });
127
1100
  }
128
- async requestPaymentDecision(decisionRequest) {
129
- const decisionResponse = await this.controlPlaneFetch('/api/sdk/payment-decisions', {
1101
+ async requestPaymentDecision(decisionRequest, protocol) {
1102
+ return this.postControlPlaneJson('/api/sdk/payment-decisions', decisionRequest, sdkPaymentDecisionResponseSchema, protocol, 'Payment decision');
1103
+ }
1104
+ async requestPaymentAuthorization(authorizationRequest, protocol) {
1105
+ return this.postControlPlaneJson('/api/sdk/payment-authorizations', authorizationRequest, sdkPaymentAuthorizationResponseSchema, protocol, 'Payment authorization');
1106
+ }
1107
+ async requestPaymentFinalization(finalizationRequest, protocol) {
1108
+ return this.postControlPlaneJson('/api/sdk/payment-finalizations', finalizationRequest, sdkPaymentDecisionResponseSchema, protocol, 'Payment finalization');
1109
+ }
1110
+ async postControlPlaneJson(path, requestBody, responseSchema, protocol, operationName) {
1111
+ const controlPlaneResponse = await this.controlPlaneFetch(path, {
130
1112
  method: 'POST',
131
1113
  headers: {
132
1114
  'content-type': 'application/json',
133
1115
  },
134
- body: JSON.stringify(decisionRequest),
1116
+ body: JSON.stringify(requestBody),
135
1117
  }, await this.getRuntimeAuthorizationHeader());
136
- const responseBody = await decisionResponse.text();
137
- try {
138
- return sdkPaymentDecisionResponseSchema.parse(JSON.parse(responseBody));
1118
+ const responseBody = await controlPlaneResponse.text();
1119
+ const parsedBody = tryParseJson(responseBody);
1120
+ if (parsedBody !== undefined) {
1121
+ try {
1122
+ return responseSchema.parse(parsedBody);
1123
+ }
1124
+ catch {
1125
+ // Fall through to request-failed handling below.
1126
+ }
139
1127
  }
140
- catch {
141
- if (!decisionResponse.ok) {
142
- throw new Error(`Payment decision failed with status ${decisionResponse.status}.`);
1128
+ const defaultFailureMessage = `${operationName} failed with status ${controlPlaneResponse.status}.`;
1129
+ if (!controlPlaneResponse.ok) {
1130
+ throw new FetchPaidError({
1131
+ kind: 'request_failed',
1132
+ protocol,
1133
+ response: createRawResponse(controlPlaneResponse.status, responseBody, controlPlaneResponse.headers),
1134
+ reason: getControlPlaneErrorMessage(parsedBody, defaultFailureMessage),
1135
+ decision: {
1136
+ outcome: 'request_failed',
1137
+ status: controlPlaneResponse.status,
1138
+ message: getControlPlaneErrorMessage(parsedBody, defaultFailureMessage),
1139
+ ...(parsedBody !== undefined ? { body: parsedBody } : {}),
1140
+ },
1141
+ });
1142
+ }
1143
+ const contractMessage = `${operationName} response did not match the SDK contract.`;
1144
+ throw new FetchPaidError({
1145
+ kind: 'request_failed',
1146
+ protocol,
1147
+ response: createRawResponse(controlPlaneResponse.status, responseBody, controlPlaneResponse.headers),
1148
+ reason: contractMessage,
1149
+ decision: {
1150
+ outcome: 'request_failed',
1151
+ status: controlPlaneResponse.status,
1152
+ message: contractMessage,
1153
+ ...(parsedBody !== undefined ? { body: parsedBody } : {}),
1154
+ },
1155
+ });
1156
+ }
1157
+ resolveDelegatedPreparedExecution(request) {
1158
+ const providerFromExecutor = request.executor?.provider;
1159
+ const requestedProvider = request.executionProvider ?? providerFromExecutor;
1160
+ if (!requestedProvider || requestedProvider === 'direct') {
1161
+ if (request.executor) {
1162
+ throw new Error('Delegated executors require a non-direct executionProvider.');
143
1163
  }
144
- throw new Error('Payment decision response was not valid JSON.');
1164
+ return null;
1165
+ }
1166
+ if (!request.executor) {
1167
+ throw new Error(`Execution provider "${requestedProvider}" requires an executor implementation.`);
145
1168
  }
1169
+ if (providerFromExecutor &&
1170
+ request.executionProvider &&
1171
+ providerFromExecutor !== request.executionProvider) {
1172
+ throw new Error(`Execution provider mismatch: request asked for "${request.executionProvider}" but executor is registered as "${providerFromExecutor}".`);
1173
+ }
1174
+ const { executor, ...requestContext } = request;
1175
+ return {
1176
+ executor,
1177
+ request: {
1178
+ ...requestContext,
1179
+ executionProvider: requestedProvider,
1180
+ },
1181
+ };
1182
+ }
1183
+ async executeWithPreparedExecutor(input) {
1184
+ try {
1185
+ return sdkDelegatedExecutionResultSchema.parse(await input.delegatedExecution.executor.execute({
1186
+ prepared: input.prepared,
1187
+ authorization: input.authorization,
1188
+ request: input.delegatedExecution.request,
1189
+ }));
1190
+ }
1191
+ catch (error) {
1192
+ return this.buildDelegatedTransportLossResult(input.prepared.challenge.protocol, error);
1193
+ }
1194
+ }
1195
+ buildDelegatedTransportLossResult(protocol, error) {
1196
+ const message = error instanceof Error
1197
+ ? error.message
1198
+ : 'Delegated executor failed before returning a normalized result.';
1199
+ return sdkDelegatedExecutionResultSchema.parse({
1200
+ protocol,
1201
+ executionStatus: 'inconclusive',
1202
+ settlementEvidenceClass: 'none',
1203
+ merchantOutcome: 'no_response',
1204
+ diagnostic: {
1205
+ code: 'merchant_transport_lost',
1206
+ message,
1207
+ },
1208
+ protocolArtifacts: {
1209
+ delegatedExecutorError: error instanceof Error
1210
+ ? {
1211
+ name: error.name,
1212
+ message: error.message,
1213
+ }
1214
+ : {
1215
+ message: String(error),
1216
+ },
1217
+ },
1218
+ });
1219
+ }
1220
+ buildDelegatedFinalizationTransportLossError(input) {
1221
+ const originalMessage = input.error instanceof Error && input.error.message.trim().length > 0
1222
+ ? ` Original error: ${input.error.message}`
1223
+ : '';
1224
+ const reason = 'Delegated execution completed, but payment finalization could not be confirmed because the control-plane request failed after dispatch. Do not automatically rerun this prepared request.'
1225
+ + originalMessage;
1226
+ const decision = {
1227
+ outcome: 'inconclusive',
1228
+ paidRequestId: input.authorization.paidRequestId,
1229
+ paymentAttemptId: input.authorization.paymentAttemptId,
1230
+ reasonCode: 'payment_finalization_transport_lost',
1231
+ reason,
1232
+ evidence: {
1233
+ stage: 'payment_finalization',
1234
+ delegatedResult: input.delegatedResult,
1235
+ transportError: input.error instanceof Error
1236
+ ? {
1237
+ name: input.error.name,
1238
+ message: input.error.message,
1239
+ }
1240
+ : {
1241
+ message: String(input.error),
1242
+ },
1243
+ },
1244
+ };
1245
+ const response = {
1246
+ kind: 'execution_inconclusive',
1247
+ protocol: input.protocol,
1248
+ response: createJsonResponse(202, decision),
1249
+ paidRequestId: input.authorization.paidRequestId,
1250
+ paymentAttemptId: input.authorization.paymentAttemptId,
1251
+ reason,
1252
+ decision,
1253
+ };
1254
+ return new FetchPaidError(response);
146
1255
  }
147
1256
  mapDecisionToPaidResponse(decision, protocol) {
1257
+ // Only allow returns normally. Every other durable or non-durable paid outcome
1258
+ // is surfaced as a typed FetchPaidError so caller control flow stays explicit.
148
1259
  switch (decision.outcome) {
149
1260
  case 'allow': {
150
1261
  const response = {
@@ -170,7 +1281,7 @@ export class AgentPayClient {
170
1281
  reason: decision.reason,
171
1282
  decision,
172
1283
  };
173
- return response;
1284
+ throw new FetchPaidError(response);
174
1285
  }
175
1286
  case 'deny': {
176
1287
  const response = {
@@ -186,7 +1297,7 @@ export class AgentPayClient {
186
1297
  ? { policyReviewEventId: decision.policyReviewEventId }
187
1298
  : {}),
188
1299
  };
189
- return response;
1300
+ throw new FetchPaidError(response);
190
1301
  }
191
1302
  case 'executing': {
192
1303
  const response = {
@@ -198,7 +1309,7 @@ export class AgentPayClient {
198
1309
  reason: decision.reason,
199
1310
  decision,
200
1311
  };
201
- return response;
1312
+ throw new FetchPaidError(response);
202
1313
  }
203
1314
  case 'inconclusive': {
204
1315
  const response = {
@@ -210,7 +1321,7 @@ export class AgentPayClient {
210
1321
  reason: decision.reason,
211
1322
  decision,
212
1323
  };
213
- return response;
1324
+ throw new FetchPaidError(response);
214
1325
  }
215
1326
  case 'execution_failed': {
216
1327
  const response = {
@@ -222,7 +1333,7 @@ export class AgentPayClient {
222
1333
  reason: decision.reason,
223
1334
  decision,
224
1335
  };
225
- return response;
1336
+ throw new FetchPaidError(response);
226
1337
  }
227
1338
  case 'preflight_failed': {
228
1339
  const response = {
@@ -234,7 +1345,7 @@ export class AgentPayClient {
234
1345
  reason: decision.reason,
235
1346
  decision,
236
1347
  };
237
- return response;
1348
+ throw new FetchPaidError(response);
238
1349
  }
239
1350
  }
240
1351
  }
@@ -270,7 +1381,8 @@ export class AgentPayClient {
270
1381
  method: 'POST',
271
1382
  }, `Bearer ${this.auth.bootstrapKey}`);
272
1383
  if (!response.ok) {
273
- throw new Error(`Runtime token exchange failed with status ${response.status}.`);
1384
+ const error = await readControlPlaneError(response, `Runtime token exchange failed with status ${response.status}.`);
1385
+ throw new Error(error.message);
274
1386
  }
275
1387
  const runtimeToken = parseRuntimeTokenResponse(await response.json());
276
1388
  this.cachedRuntimeToken = {
@@ -280,17 +1392,35 @@ export class AgentPayClient {
280
1392
  return runtimeToken.token;
281
1393
  }
282
1394
  async controlPlaneFetch(path, init, authorizationHeader) {
283
- return this.fetchImpl(`${this.controlPlaneBaseUrl}${path}`, {
284
- ...init,
285
- headers: {
286
- ...(this.headers ?? {}),
287
- ...(normalizeHeaders(init.headers) ?? {}),
288
- Authorization: authorizationHeader,
289
- },
290
- });
1395
+ // Every control-plane call carries the SDK version header so incompatible
1396
+ // client/server contract mismatches can fail fast.
1397
+ const controlPlaneUrl = `${this.controlPlaneBaseUrl}${path}`;
1398
+ try {
1399
+ return await this.fetchImpl(controlPlaneUrl, {
1400
+ ...init,
1401
+ headers: {
1402
+ ...(this.headers ?? {}),
1403
+ ...(normalizeHeaders(init.headers) ?? {}),
1404
+ Authorization: authorizationHeader,
1405
+ [sdkClientVersionHeaderName]: sdkClientVersion,
1406
+ },
1407
+ });
1408
+ }
1409
+ catch (error) {
1410
+ throw new Error(buildControlPlaneTransportErrorMessage(controlPlaneUrl, error), {
1411
+ cause: error,
1412
+ });
1413
+ }
291
1414
  }
292
1415
  }
1416
+ /** Small factory wrapper for callers that prefer a function export. */
293
1417
  export function createAgentPayClient(options) {
294
1418
  return new AgentPayClient(options);
295
1419
  }
1420
+ export * from './agent-harness.js';
1421
+ export * from './contracts.js';
1422
+ export * from './executors.js';
1423
+ export * from './harness-instructions.js';
1424
+ export { detectChallengeFromResponse } from './challenge-detection.js';
1425
+ export { sdkClientVersion, sdkClientVersionHeaderName } from './version.js';
296
1426
  //# sourceMappingURL=index.js.map