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