@402flow/sdk 0.1.0-alpha.13 → 0.1.0-alpha.15

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,7 +1,19 @@
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, sdkPaymentDecisionRequestSchema, sdkPaymentDecisionResponseSchema, sdkReceiptResponseSchema, } from './contracts.js';
4
12
  import { sdkClientVersion, sdkClientVersionHeaderName, } from './version.js';
13
+ /**
14
+ * Thrown for all non-success paid outcomes. The original typed failure payload is
15
+ * preserved on details so callers can branch on kind without reparsing responses.
16
+ */
5
17
  export class FetchPaidError extends Error {
6
18
  details;
7
19
  kind;
@@ -33,6 +45,7 @@ export class FetchPaidError extends Error {
33
45
  Object.setPrototypeOf(this, new.target.prototype);
34
46
  }
35
47
  }
48
+ /** Type guard for callers that catch unknown errors around fetchPaid flows. */
36
49
  export function isFetchPaidError(error) {
37
50
  return error instanceof FetchPaidError;
38
51
  }
@@ -128,7 +141,30 @@ function tryParseJson(value) {
128
141
  return undefined;
129
142
  }
130
143
  }
131
- function getReplayableRequestBody(body) {
144
+ function describeRequestBodyType(body) {
145
+ if (body === undefined || body === null) {
146
+ return 'empty';
147
+ }
148
+ if (typeof body === 'string') {
149
+ return 'string';
150
+ }
151
+ if (body instanceof URLSearchParams) {
152
+ return 'URLSearchParams';
153
+ }
154
+ if (typeof body === 'object' && 'constructor' in body) {
155
+ const constructorName = body.constructor?.name;
156
+ if (typeof constructorName === 'string' && constructorName.length > 0) {
157
+ return constructorName;
158
+ }
159
+ }
160
+ return typeof body;
161
+ }
162
+ /** Returns true when a request body can be replayed exactly across paid flows. */
163
+ export function isReplayableRequestBody(body) {
164
+ return typeof body === 'string' || body instanceof URLSearchParams;
165
+ }
166
+ /** Serialize a paid-flow request body into the exact replayable wire representation. */
167
+ export function toReplayableRequestBody(body) {
132
168
  if (body === undefined || body === null) {
133
169
  return undefined;
134
170
  }
@@ -138,7 +174,30 @@ function getReplayableRequestBody(body) {
138
174
  if (body instanceof URLSearchParams) {
139
175
  return body.toString();
140
176
  }
141
- throw new Error('Paid requests currently support replayable string and URLSearchParams bodies when routed through the control plane.');
177
+ throw new Error(`Paid requests require replayable string or URLSearchParams bodies. Received ${describeRequestBodyType(body)}. Convert JSON payloads with createJsonRequestBody(...) or form payloads with createFormUrlEncodedBody(...).`);
178
+ }
179
+ /** Helper for callers that want an explicit JSON-string body for paid flows. */
180
+ export function createJsonRequestBody(payload) {
181
+ return JSON.stringify(payload);
182
+ }
183
+ /** Helper for callers that want a replayable form body for paid flows. */
184
+ export function createFormUrlEncodedBody(values) {
185
+ const params = new URLSearchParams();
186
+ for (const [key, rawValue] of Object.entries(values)) {
187
+ if (Array.isArray(rawValue)) {
188
+ for (const entry of rawValue) {
189
+ params.append(key, String(entry));
190
+ }
191
+ continue;
192
+ }
193
+ params.append(key, String(rawValue));
194
+ }
195
+ return params;
196
+ }
197
+ // Preparation and paid execution may need to replay the exact request body, so
198
+ // only body types that can be losslessly serialized are accepted here.
199
+ function getReplayableRequestBody(body) {
200
+ return toReplayableRequestBody(body);
142
201
  }
143
202
  function hashRequestBody(body) {
144
203
  if (!body) {
@@ -163,6 +222,604 @@ function parseRuntimeTokenResponse(payload) {
163
222
  expiresAt,
164
223
  };
165
224
  }
225
+ function createPreparedHttpRequest(input, init) {
226
+ const requestBody = getReplayableRequestBody(init.body);
227
+ return paidRequestHttpRequestSchema.parse({
228
+ url: input,
229
+ method: (init.method ?? 'GET').toUpperCase(),
230
+ headers: normalizeHeaders(init.headers),
231
+ body: requestBody,
232
+ bodyHash: hashRequestBody(requestBody),
233
+ });
234
+ }
235
+ function createPreparationAttribution(source, authority, note) {
236
+ return {
237
+ source,
238
+ authority,
239
+ ...(note ? { note } : {}),
240
+ };
241
+ }
242
+ function readExternalMetadata(options) {
243
+ return options.externalMetadata;
244
+ }
245
+ function readSchemaType(value) {
246
+ if (typeof value === 'string' && value.length > 0) {
247
+ return value;
248
+ }
249
+ if (Array.isArray(value)) {
250
+ const firstString = value.find((entry) => typeof entry === 'string' && entry.length > 0);
251
+ if (firstString) {
252
+ return firstString;
253
+ }
254
+ }
255
+ return undefined;
256
+ }
257
+ function readMerchantFieldsFromObjectSchema(objectSchema) {
258
+ const properties = isRecord(objectSchema.properties)
259
+ ? objectSchema.properties
260
+ : undefined;
261
+ const required = Array.isArray(objectSchema.required)
262
+ ? new Set(objectSchema.required.filter((entry) => typeof entry === 'string' && entry.length > 0))
263
+ : new Set();
264
+ const fields = new Map();
265
+ for (const [name, schema] of Object.entries(properties ?? {})) {
266
+ const propertySchema = isRecord(schema) ? schema : {};
267
+ fields.set(name.toLowerCase(), {
268
+ name,
269
+ ...(readSchemaType(propertySchema.type) ? { type: readSchemaType(propertySchema.type) } : {}),
270
+ ...(readStringValue(propertySchema.description)
271
+ ? { description: readStringValue(propertySchema.description) }
272
+ : {}),
273
+ ...(required.has(name) ? { required: true } : {}),
274
+ });
275
+ }
276
+ for (const fieldName of required) {
277
+ if (!fields.has(fieldName.toLowerCase())) {
278
+ fields.set(fieldName.toLowerCase(), {
279
+ name: fieldName,
280
+ required: true,
281
+ });
282
+ }
283
+ }
284
+ return Array.from(fields.values());
285
+ }
286
+ function inferFieldTypeFromValue(value) {
287
+ if (Array.isArray(value)) {
288
+ return 'array';
289
+ }
290
+ if (value === null) {
291
+ return undefined;
292
+ }
293
+ switch (typeof value) {
294
+ case 'string':
295
+ return 'string';
296
+ case 'number':
297
+ return Number.isInteger(value) ? 'integer' : 'number';
298
+ case 'boolean':
299
+ return 'boolean';
300
+ case 'object':
301
+ return 'object';
302
+ default:
303
+ return undefined;
304
+ }
305
+ }
306
+ function inferFieldsFromQueryParamExample(queryParams) {
307
+ return Object.entries(queryParams).map(([name, value]) => ({
308
+ name,
309
+ ...(inferFieldTypeFromValue(value) ? { type: inferFieldTypeFromValue(value) } : {}),
310
+ required: true,
311
+ }));
312
+ }
313
+ async function resolveJsonSchemaRef(schema, fetchImpl) {
314
+ const ref = readStringValue(schema.$ref);
315
+ if (!ref) {
316
+ return schema;
317
+ }
318
+ try {
319
+ const url = new URL(ref);
320
+ if (!['http:', 'https:'].includes(url.protocol)) {
321
+ return undefined;
322
+ }
323
+ const response = await fetchImpl(url.toString());
324
+ if (!response.ok) {
325
+ return undefined;
326
+ }
327
+ const parsed = await response.json();
328
+ return isRecord(parsed) ? parsed : undefined;
329
+ }
330
+ catch {
331
+ return undefined;
332
+ }
333
+ }
334
+ async function readMerchantPreparationMetadataFromInputSchema(inputSchema, requestBodyExample, queryParamExample, fetchImpl) {
335
+ const bodyType = readStringValue(inputSchema.bodyType)
336
+ ?? (isRecord(inputSchema.properties)
337
+ && isRecord(inputSchema.properties.bodyType)
338
+ ? readStringValue(inputSchema.properties.bodyType.const)
339
+ : undefined);
340
+ const bodySchema = isRecord(inputSchema.body)
341
+ ? inputSchema.body
342
+ : isRecord(inputSchema.properties)
343
+ && isRecord(inputSchema.properties.body)
344
+ ? inputSchema.properties.body
345
+ : undefined;
346
+ const queryParamSchema = isRecord(inputSchema.queryParams)
347
+ ? inputSchema.queryParams
348
+ : isRecord(inputSchema.properties)
349
+ && isRecord(inputSchema.properties.queryParams)
350
+ ? inputSchema.properties.queryParams
351
+ : undefined;
352
+ const requestBodyFields = bodySchema
353
+ ? readMerchantFieldsFromObjectSchema(bodySchema)
354
+ : undefined;
355
+ const resolvedQueryParamSchema = queryParamSchema
356
+ ? await resolveJsonSchemaRef(queryParamSchema, fetchImpl)
357
+ : undefined;
358
+ const requestQueryParams = resolvedQueryParamSchema
359
+ ? readMerchantFieldsFromObjectSchema(resolvedQueryParamSchema)
360
+ : queryParamExample
361
+ ? inferFieldsFromQueryParamExample(queryParamExample)
362
+ : undefined;
363
+ const hasBodyFields = requestBodyFields && requestBodyFields.length > 0;
364
+ const hasQueryFields = requestQueryParams && requestQueryParams.length > 0;
365
+ if (!bodyType && !requestBodyExample && !hasBodyFields && !hasQueryFields) {
366
+ return undefined;
367
+ }
368
+ return {
369
+ ...(bodyType ? { requestBodyType: bodyType } : {}),
370
+ ...(requestBodyExample ? { requestBodyExample } : {}),
371
+ ...(hasBodyFields
372
+ ? { requestBodyFields }
373
+ : {}),
374
+ ...(hasQueryFields
375
+ ? { requestQueryParams }
376
+ : {}),
377
+ notes: ['Request hints derived from merchant challenge metadata.'],
378
+ };
379
+ }
380
+ async function readMerchantPreparationMetadata(challenge, fetchImpl) {
381
+ if (!challenge) {
382
+ return undefined;
383
+ }
384
+ const paymentRequiredPayload = tryParsePaymentRequiredHeader(challenge.headers['payment-required']);
385
+ const payload = unwrapChallengePayload(paymentRequiredPayload ?? challenge.body);
386
+ if (!isRecord(payload)) {
387
+ return undefined;
388
+ }
389
+ const accepts = Array.isArray(payload.accepts) ? payload.accepts : [];
390
+ for (const accept of accepts) {
391
+ if (!isRecord(accept) || !isRecord(accept.extra) || !isRecord(accept.extra.outputSchema)) {
392
+ continue;
393
+ }
394
+ const outputSchema = accept.extra.outputSchema;
395
+ const metadata = isRecord(outputSchema.input)
396
+ ? await readMerchantPreparationMetadataFromInputSchema(outputSchema.input, undefined, undefined, fetchImpl)
397
+ : undefined;
398
+ if (metadata) {
399
+ return metadata;
400
+ }
401
+ }
402
+ const bazaar = isRecord(payload.extensions) && isRecord(payload.extensions.bazaar)
403
+ ? payload.extensions.bazaar
404
+ : undefined;
405
+ const bazaarInfoInput = isRecord(bazaar?.info) && isRecord(bazaar.info.input)
406
+ ? bazaar.info.input
407
+ : undefined;
408
+ const bazaarRequestBodyExample = isRecord(bazaarInfoInput?.body)
409
+ ? JSON.stringify(bazaarInfoInput.body)
410
+ : undefined;
411
+ const bazaarQueryParamExample = isRecord(bazaarInfoInput?.queryParams)
412
+ ? bazaarInfoInput.queryParams
413
+ : undefined;
414
+ const bazaarInputSchema = isRecord(bazaar?.schema)
415
+ && isRecord(bazaar.schema.properties)
416
+ && isRecord(bazaar.schema.properties.input)
417
+ ? bazaar.schema.properties.input
418
+ : undefined;
419
+ if (bazaarInputSchema) {
420
+ return readMerchantPreparationMetadataFromInputSchema(bazaarInputSchema, bazaarRequestBodyExample, bazaarQueryParamExample, fetchImpl);
421
+ }
422
+ return undefined;
423
+ }
424
+ function pickPreparedHintValue(options, merchantChallengeMetadata, key) {
425
+ if (merchantChallengeMetadata?.[key]) {
426
+ return {
427
+ value: merchantChallengeMetadata[key],
428
+ attribution: createPreparationAttribution('merchant_challenge', 'authoritative'),
429
+ };
430
+ }
431
+ const externalMetadata = readExternalMetadata(options);
432
+ if (externalMetadata?.[key]) {
433
+ return {
434
+ value: externalMetadata[key],
435
+ attribution: createPreparationAttribution('external_metadata', 'advisory'),
436
+ };
437
+ }
438
+ return undefined;
439
+ }
440
+ function mergePreparedHintFields(options, merchantChallengeMetadata, key) {
441
+ const fields = new Map();
442
+ for (const field of merchantChallengeMetadata?.[key] ?? []) {
443
+ fields.set(field.name.toLowerCase(), {
444
+ ...field,
445
+ attribution: createPreparationAttribution('merchant_challenge', 'authoritative'),
446
+ });
447
+ }
448
+ for (const field of readExternalMetadata(options)?.[key] ?? []) {
449
+ const normalizedName = field.name.toLowerCase();
450
+ if (fields.has(normalizedName)) {
451
+ continue;
452
+ }
453
+ fields.set(normalizedName, {
454
+ ...field,
455
+ attribution: createPreparationAttribution('external_metadata', 'advisory'),
456
+ });
457
+ }
458
+ return Array.from(fields.values());
459
+ }
460
+ async function buildPreparedRequestHints(options, fetchImpl, challenge) {
461
+ // Hint assembly merges optional caller metadata with merchant-authoritative
462
+ // challenge metadata while preserving attribution for every returned field.
463
+ const merchantChallengeMetadata = await readMerchantPreparationMetadata(challenge, fetchImpl);
464
+ const externalMetadata = readExternalMetadata(options);
465
+ return {
466
+ ...(pickPreparedHintValue(options, merchantChallengeMetadata, 'description')
467
+ ? {
468
+ description: pickPreparedHintValue(options, merchantChallengeMetadata, 'description'),
469
+ }
470
+ : {}),
471
+ ...(pickPreparedHintValue(options, merchantChallengeMetadata, 'requestBodyType')
472
+ ? {
473
+ requestBodyType: pickPreparedHintValue(options, merchantChallengeMetadata, 'requestBodyType'),
474
+ }
475
+ : {}),
476
+ ...(pickPreparedHintValue(options, merchantChallengeMetadata, 'requestBodyExample')
477
+ ? {
478
+ requestBodyExample: pickPreparedHintValue(options, merchantChallengeMetadata, 'requestBodyExample'),
479
+ }
480
+ : {}),
481
+ requestBodyFields: mergePreparedHintFields(options, merchantChallengeMetadata, 'requestBodyFields'),
482
+ requestQueryParams: mergePreparedHintFields(options, merchantChallengeMetadata, 'requestQueryParams'),
483
+ requestPathParams: mergePreparedHintFields(options, merchantChallengeMetadata, 'requestPathParams'),
484
+ notes: [
485
+ ...(merchantChallengeMetadata?.notes ?? []).map((value) => ({
486
+ value,
487
+ attribution: createPreparationAttribution('merchant_challenge', 'authoritative'),
488
+ })),
489
+ ...(externalMetadata?.notes ?? []).map((value) => ({
490
+ value,
491
+ attribution: createPreparationAttribution('external_metadata', 'advisory'),
492
+ })),
493
+ ],
494
+ };
495
+ }
496
+ function isJsonContentType(value) {
497
+ return value?.toLowerCase().includes('application/json') ?? false;
498
+ }
499
+ function matchesExpectedFieldType(value, expectedType) {
500
+ switch (expectedType?.toLowerCase()) {
501
+ case 'string':
502
+ return typeof value === 'string';
503
+ case 'number':
504
+ return typeof value === 'number' && Number.isFinite(value);
505
+ case 'integer':
506
+ case 'int':
507
+ return typeof value === 'number' && Number.isInteger(value);
508
+ case 'boolean':
509
+ return typeof value === 'boolean';
510
+ case 'object':
511
+ return isRecord(value);
512
+ case 'array':
513
+ return Array.isArray(value);
514
+ default:
515
+ return true;
516
+ }
517
+ }
518
+ function buildPreparedValidationIssues(request, hints) {
519
+ // Validation is intentionally narrow: it checks only request-shape issues the
520
+ // SDK can defend from available hints, not task-specific semantic correctness.
521
+ const issues = [];
522
+ const contentType = request.headers?.['content-type'];
523
+ const requestBodyType = hints.requestBodyType?.value.toLowerCase();
524
+ const requiredBodyFields = hints.requestBodyFields.filter((field) => field.required);
525
+ if (requestBodyType === 'json'
526
+ && request.body !== undefined
527
+ && !isJsonContentType(contentType)) {
528
+ issues.push({
529
+ location: 'headers',
530
+ field: 'content-type',
531
+ code: 'unsupported_content_type',
532
+ message: 'Request body is expected to be JSON but content-type is not application/json.',
533
+ source: hints.requestBodyType?.attribution.source ?? 'external_metadata',
534
+ blocking: true,
535
+ severity: 'error',
536
+ suggestedFix: 'Send the request with content-type: application/json.',
537
+ });
538
+ }
539
+ if (hints.requestBodyFields.length > 0) {
540
+ if (request.body === undefined) {
541
+ for (const field of requiredBodyFields) {
542
+ issues.push({
543
+ location: 'body',
544
+ field: field.name,
545
+ code: 'missing_required_field',
546
+ message: `Required request body field "${field.name}" is missing.`,
547
+ source: field.attribution.source,
548
+ blocking: true,
549
+ severity: 'error',
550
+ suggestedFix: `Add the required body field "${field.name}" before execution.`,
551
+ });
552
+ }
553
+ }
554
+ else {
555
+ const parsedBody = tryParseJson(request.body);
556
+ if (parsedBody === undefined) {
557
+ issues.push({
558
+ location: 'body',
559
+ field: 'body',
560
+ code: 'malformed_candidate_value',
561
+ message: 'Request body must be valid JSON to satisfy the declared body fields.',
562
+ source: hints.requestBodyType?.attribution.source
563
+ ?? hints.requestBodyFields[0]?.attribution.source
564
+ ?? 'external_metadata',
565
+ blocking: true,
566
+ severity: 'error',
567
+ suggestedFix: 'Send a valid JSON object body that satisfies the declared fields.',
568
+ });
569
+ }
570
+ else if (!isRecord(parsedBody)) {
571
+ issues.push({
572
+ location: 'body',
573
+ field: 'body',
574
+ code: 'request_shape_conflicts_with_hint',
575
+ message: 'Request body must be a JSON object to satisfy the declared body fields.',
576
+ source: hints.requestBodyFields[0]?.attribution.source
577
+ ?? hints.requestBodyType?.attribution.source
578
+ ?? 'external_metadata',
579
+ blocking: true,
580
+ severity: 'error',
581
+ suggestedFix: 'Send a JSON object body whose keys match the declared fields.',
582
+ });
583
+ }
584
+ else {
585
+ for (const field of requiredBodyFields) {
586
+ if (!(field.name in parsedBody)) {
587
+ issues.push({
588
+ location: 'body',
589
+ field: field.name,
590
+ code: 'missing_required_field',
591
+ message: `Required request body field "${field.name}" is missing.`,
592
+ source: field.attribution.source,
593
+ blocking: true,
594
+ severity: 'error',
595
+ suggestedFix: `Add the required body field "${field.name}" before execution.`,
596
+ });
597
+ }
598
+ }
599
+ for (const field of hints.requestBodyFields) {
600
+ if (!(field.name in parsedBody)) {
601
+ continue;
602
+ }
603
+ if (!matchesExpectedFieldType(parsedBody[field.name], field.type)) {
604
+ issues.push({
605
+ location: 'body',
606
+ field: field.name,
607
+ code: 'malformed_candidate_value',
608
+ message: `Request body field "${field.name}" does not match the expected type${field.type ? ` "${field.type}"` : ''}.`,
609
+ source: field.attribution.source,
610
+ blocking: true,
611
+ severity: 'error',
612
+ suggestedFix: `Set "${field.name}" to a value compatible with the declared type${field.type ? ` "${field.type}"` : ''}.`,
613
+ });
614
+ }
615
+ }
616
+ }
617
+ }
618
+ }
619
+ const url = new URL(request.url);
620
+ for (const field of hints.requestQueryParams.filter((entry) => entry.required)) {
621
+ if (!url.searchParams.has(field.name)) {
622
+ issues.push({
623
+ location: 'query',
624
+ field: field.name,
625
+ code: 'missing_required_query_param',
626
+ message: `Required query parameter "${field.name}" is missing.`,
627
+ source: field.attribution.source,
628
+ blocking: true,
629
+ severity: 'error',
630
+ suggestedFix: `Add the required query parameter "${field.name}" before execution.`,
631
+ });
632
+ }
633
+ }
634
+ for (const field of hints.requestPathParams.filter((entry) => entry.required)) {
635
+ if (url.pathname.includes(`{${field.name}}`)
636
+ || url.pathname.includes(`:${field.name}`)) {
637
+ issues.push({
638
+ location: 'path',
639
+ field: field.name,
640
+ code: 'missing_required_path_param',
641
+ message: `Required path parameter "${field.name}" is still unresolved in the request URL.`,
642
+ source: field.attribution.source,
643
+ blocking: true,
644
+ severity: 'error',
645
+ suggestedFix: `Replace the unresolved path placeholder for "${field.name}" before execution.`,
646
+ });
647
+ }
648
+ }
649
+ const seen = new Set();
650
+ return issues.filter((issue) => {
651
+ const key = [
652
+ issue.location,
653
+ issue.field.toLowerCase(),
654
+ issue.code,
655
+ issue.source,
656
+ ].join(':');
657
+ if (seen.has(key)) {
658
+ return false;
659
+ }
660
+ seen.add(key);
661
+ return true;
662
+ });
663
+ }
664
+ function derivePreparedNextAction(kind, validationIssues) {
665
+ if (kind === 'passthrough') {
666
+ return 'treat_as_passthrough';
667
+ }
668
+ if (validationIssues.some((issue) => issue.blocking)) {
669
+ return 'revise_request';
670
+ }
671
+ return 'execute';
672
+ }
673
+ function readStringValue(value) {
674
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
675
+ }
676
+ function readIntegerValue(value) {
677
+ if (typeof value === 'number' && Number.isInteger(value)) {
678
+ return value;
679
+ }
680
+ if (typeof value === 'string' && /^\d+$/.test(value)) {
681
+ return Number.parseInt(value, 10);
682
+ }
683
+ return undefined;
684
+ }
685
+ function readPrecisionFromAmount(amount) {
686
+ if (!amount) {
687
+ return undefined;
688
+ }
689
+ const [, fractionalPart = ''] = amount.split('.');
690
+ return fractionalPart.length;
691
+ }
692
+ function formatMinorUnitsAsAmount(amountMinor, precision) {
693
+ if (precision === 0) {
694
+ return amountMinor;
695
+ }
696
+ const normalizedMinor = amountMinor.replace(/^0+(?=\d)/, '') || '0';
697
+ const paddedMinor = normalizedMinor.padStart(precision + 1, '0');
698
+ const wholePart = paddedMinor.slice(0, -precision) || '0';
699
+ const fractionalPart = paddedMinor.slice(-precision);
700
+ return `${wholePart}.${fractionalPart}`;
701
+ }
702
+ function tryParsePaymentRequiredHeader(value) {
703
+ if (!value) {
704
+ return undefined;
705
+ }
706
+ try {
707
+ return JSON.parse(Buffer.from(value, 'base64').toString('utf8'));
708
+ }
709
+ catch {
710
+ // Fall through.
711
+ }
712
+ return tryParseJson(value);
713
+ }
714
+ function unwrapChallengePayload(payload) {
715
+ if (!isRecord(payload)) {
716
+ return undefined;
717
+ }
718
+ if (isRecord(payload.challenge)) {
719
+ return payload.challenge;
720
+ }
721
+ return payload;
722
+ }
723
+ function parseAuthenticateHeaderParameters(header) {
724
+ if (!header) {
725
+ return new Map();
726
+ }
727
+ const matches = header.matchAll(/([a-zA-Z0-9_-]+)="([^"]+)"/g);
728
+ const parameters = new Map();
729
+ for (const match of matches) {
730
+ const [, key, value] = match;
731
+ if (key && value) {
732
+ parameters.set(key.toLowerCase(), value);
733
+ }
734
+ }
735
+ return parameters;
736
+ }
737
+ function buildPreparedPaymentRequirement(challenge) {
738
+ // Payment terms are normalized into one portable structure so callers do not
739
+ // need protocol-specific parsing logic in their agent or application layer.
740
+ const provenance = createPreparationAttribution('merchant_challenge', 'authoritative');
741
+ const paymentRequiredPayload = tryParsePaymentRequiredHeader(challenge.headers['payment-required']);
742
+ const payload = unwrapChallengePayload(paymentRequiredPayload ?? challenge.body);
743
+ if (isRecord(payload)) {
744
+ const resource = isRecord(payload.resource) ? payload.resource : undefined;
745
+ const accepts = Array.isArray(payload.accepts) ? payload.accepts : undefined;
746
+ const primaryAccept = accepts?.find((candidate) => isRecord(candidate));
747
+ if (primaryAccept && isRecord(primaryAccept)) {
748
+ const amountMinor = readStringValue(primaryAccept.amount)
749
+ ?? readStringValue(primaryAccept.maxAmountRequired);
750
+ const precision = readIntegerValue(primaryAccept.precision)
751
+ ?? (isRecord(primaryAccept.extra)
752
+ ? readIntegerValue(primaryAccept.extra.precision)
753
+ ?? readIntegerValue(primaryAccept.extra.decimals)
754
+ : undefined);
755
+ const amount = amountMinor && precision !== undefined
756
+ ? formatMinorUnitsAsAmount(amountMinor, precision)
757
+ : undefined;
758
+ return {
759
+ protocol: challenge.protocol,
760
+ ...(readStringValue(resource?.description)
761
+ ? { description: readStringValue(resource?.description) }
762
+ : {}),
763
+ ...(readStringValue(primaryAccept.asset)
764
+ ? { asset: readStringValue(primaryAccept.asset) }
765
+ : {}),
766
+ ...(readStringValue(primaryAccept.network)
767
+ ? { network: readStringValue(primaryAccept.network) }
768
+ : {}),
769
+ ...(readStringValue(primaryAccept.payTo)
770
+ ? { payee: readStringValue(primaryAccept.payTo) }
771
+ : {}),
772
+ ...(readStringValue(primaryAccept.amount)
773
+ ? { amountType: 'exact' }
774
+ : readStringValue(primaryAccept.maxAmountRequired)
775
+ ? { amountType: 'max' }
776
+ : {}),
777
+ ...(amount ? { amount } : {}),
778
+ ...(amountMinor ? { amountMinor } : {}),
779
+ ...(precision !== undefined ? { precision } : {}),
780
+ provenance,
781
+ };
782
+ }
783
+ }
784
+ const explicitAmount = readStringValue(challenge.headers['x-payment-amount']);
785
+ const explicitPrecision = readIntegerValue(challenge.headers['x-payment-precision'])
786
+ ?? readPrecisionFromAmount(explicitAmount);
787
+ if (explicitAmount || challenge.headers['www-authenticate']) {
788
+ const authenticateParameters = parseAuthenticateHeaderParameters(challenge.headers['www-authenticate']);
789
+ const amount = explicitAmount ?? authenticateParameters.get('amount');
790
+ const precision = explicitPrecision ?? readPrecisionFromAmount(amount);
791
+ const asset = readStringValue(challenge.headers['x-payment-asset'])
792
+ ?? authenticateParameters.get('asset');
793
+ const payee = readStringValue(challenge.headers['x-payment-payee'])
794
+ ?? authenticateParameters.get('payee');
795
+ const network = readStringValue(challenge.headers['x-payment-network'])
796
+ ?? authenticateParameters.get('network');
797
+ const amountMinor = amount && precision !== undefined
798
+ ? monetaryAmountToMinorUnits(amount, precision)
799
+ : undefined;
800
+ return {
801
+ protocol: challenge.protocol,
802
+ ...(asset ? { asset } : {}),
803
+ ...(network ? { network } : {}),
804
+ ...(payee ? { payee } : {}),
805
+ ...(amount ? { amount } : {}),
806
+ ...(amountMinor ? { amountMinor } : {}),
807
+ ...(precision !== undefined ? { precision } : {}),
808
+ ...(amount ? { amountType: 'exact' } : {}),
809
+ provenance,
810
+ };
811
+ }
812
+ return {
813
+ protocol: challenge.protocol,
814
+ provenance,
815
+ };
816
+ }
817
+ /**
818
+ * Client bound to one organization/agent identity and one 402flow control plane.
819
+ *
820
+ * Most integrations either call fetchPaid() directly or use the explicit
821
+ * preparePaidRequest() -> executePreparedRequest() flow.
822
+ */
166
823
  export class AgentPayClient {
167
824
  controlPlaneBaseUrl;
168
825
  auth;
@@ -181,6 +838,82 @@ export class AgentPayClient {
181
838
  this.fetchImpl = options.fetch ?? fetch;
182
839
  this.headers = options.headers;
183
840
  }
841
+ /**
842
+ * Probe or reuse a merchant challenge and return a normalized preparation
843
+ * result the caller can inspect before paying.
844
+ */
845
+ async preparePaidRequest(input, init = {}, options = {}) {
846
+ const request = createPreparedHttpRequest(input, init);
847
+ if (options.challenge) {
848
+ const hints = await buildPreparedRequestHints(options, this.fetchImpl, options.challenge);
849
+ const validationIssues = buildPreparedValidationIssues(request, hints);
850
+ return {
851
+ kind: 'ready',
852
+ protocol: options.challenge.protocol,
853
+ request,
854
+ challenge: paidRequestChallengeSchema.parse(options.challenge),
855
+ ...(buildPreparedPaymentRequirement(options.challenge)
856
+ ? {
857
+ paymentRequirement: buildPreparedPaymentRequirement(options.challenge),
858
+ }
859
+ : {}),
860
+ hints,
861
+ validationIssues,
862
+ nextAction: derivePreparedNextAction('ready', validationIssues),
863
+ };
864
+ }
865
+ const initialResponse = await this.fetchImpl(input, init);
866
+ const challenge = await detectChallengeFromResponse(initialResponse);
867
+ const probe = {
868
+ responseStatus: initialResponse.status,
869
+ confirmedAt: new Date().toISOString(),
870
+ };
871
+ const hints = await buildPreparedRequestHints(options, this.fetchImpl, challenge);
872
+ const validationIssues = buildPreparedValidationIssues(request, hints);
873
+ if (!challenge) {
874
+ return {
875
+ kind: 'passthrough',
876
+ protocol: 'none',
877
+ request,
878
+ hints,
879
+ probe,
880
+ validationIssues,
881
+ nextAction: derivePreparedNextAction('passthrough', validationIssues),
882
+ };
883
+ }
884
+ return {
885
+ kind: 'ready',
886
+ protocol: challenge.protocol,
887
+ request,
888
+ challenge: paidRequestChallengeSchema.parse(challenge),
889
+ ...(buildPreparedPaymentRequirement(challenge)
890
+ ? { paymentRequirement: buildPreparedPaymentRequirement(challenge) }
891
+ : {}),
892
+ hints,
893
+ probe,
894
+ validationIssues,
895
+ nextAction: derivePreparedNextAction('ready', validationIssues),
896
+ };
897
+ }
898
+ /**
899
+ * Execute the exact request that was previously prepared, without re-probing
900
+ * the merchant first.
901
+ */
902
+ async executePreparedRequest(prepared, request = {}) {
903
+ return this.fetchPaid(prepared.request.url, {
904
+ method: prepared.request.method,
905
+ ...(prepared.request.headers ? { headers: prepared.request.headers } : {}),
906
+ ...(prepared.request.body !== undefined ? { body: prepared.request.body } : {}),
907
+ }, {
908
+ ...request,
909
+ challenge: prepared.challenge,
910
+ });
911
+ }
912
+ /**
913
+ * Fast-path helper that probes when needed, asks the control plane for a paid
914
+ * execution decision, and returns either passthrough or success. All non-success
915
+ * paid outcomes are thrown as FetchPaidError.
916
+ */
184
917
  async fetchPaid(input, init = {}, request) {
185
918
  let challenge = request.challenge;
186
919
  if (!challenge) {
@@ -198,6 +931,7 @@ export class AgentPayClient {
198
931
  const decision = await this.requestPaymentDecision(decisionRequest, challenge.protocol);
199
932
  return this.mapDecisionToPaidResponse(decision, challenge.protocol);
200
933
  }
934
+ /** Lookup a durable receipt by id through the control plane. */
201
935
  async lookupReceipt(receiptId) {
202
936
  const response = await this.controlPlaneFetch(`/api/sdk/receipts/${receiptId}`, {
203
937
  method: 'GET',
@@ -209,20 +943,13 @@ export class AgentPayClient {
209
943
  return sdkReceiptResponseSchema.parse(await response.json());
210
944
  }
211
945
  createDecisionRequest(input, init, request, challenge) {
212
- const requestBody = getReplayableRequestBody(init.body);
213
946
  const { challenge: _challenge, idempotencyKey, ...requestContext } = request;
214
947
  return sdkPaymentDecisionRequestSchema.parse({
215
948
  context: {
216
949
  ...this.identity,
217
950
  ...requestContext,
218
951
  },
219
- request: {
220
- url: input,
221
- method: (init.method ?? 'GET').toUpperCase(),
222
- headers: normalizeHeaders(init.headers),
223
- body: requestBody,
224
- bodyHash: hashRequestBody(requestBody),
225
- },
952
+ request: createPreparedHttpRequest(input, init),
226
953
  challenge: {
227
954
  protocol: challenge.protocol,
228
955
  headers: challenge.headers,
@@ -232,6 +959,9 @@ export class AgentPayClient {
232
959
  });
233
960
  }
234
961
  async requestPaymentDecision(decisionRequest, protocol) {
962
+ // The control plane is the single source of truth for payment policy and
963
+ // receipt persistence. Any response that does not match the SDK contract is
964
+ // downgraded to request_failed.
235
965
  const decisionResponse = await this.controlPlaneFetch('/api/sdk/payment-decisions', {
236
966
  method: 'POST',
237
967
  headers: {
@@ -277,6 +1007,8 @@ export class AgentPayClient {
277
1007
  });
278
1008
  }
279
1009
  mapDecisionToPaidResponse(decision, protocol) {
1010
+ // Only allow returns normally. Every other durable or non-durable paid outcome
1011
+ // is surfaced as a typed FetchPaidError so caller control flow stays explicit.
280
1012
  switch (decision.outcome) {
281
1013
  case 'allow': {
282
1014
  const response = {
@@ -413,6 +1145,8 @@ export class AgentPayClient {
413
1145
  return runtimeToken.token;
414
1146
  }
415
1147
  async controlPlaneFetch(path, init, authorizationHeader) {
1148
+ // Every control-plane call carries the SDK version header so incompatible
1149
+ // client/server contract mismatches can fail fast.
416
1150
  return this.fetchImpl(`${this.controlPlaneBaseUrl}${path}`, {
417
1151
  ...init,
418
1152
  headers: {
@@ -424,9 +1158,11 @@ export class AgentPayClient {
424
1158
  });
425
1159
  }
426
1160
  }
1161
+ /** Small factory wrapper for callers that prefer a function export. */
427
1162
  export function createAgentPayClient(options) {
428
1163
  return new AgentPayClient(options);
429
1164
  }
1165
+ export * from './agent-harness.js';
430
1166
  export * from './contracts.js';
431
1167
  export { detectChallengeFromResponse } from './challenge-detection.js';
432
1168
  export { sdkClientVersion, sdkClientVersionHeaderName } from './version.js';