@402flow/sdk 0.1.0-alpha.2 → 0.1.0-alpha.21
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 +368 -23
- package/dist/agent-harness.d.ts +163 -0
- package/dist/agent-harness.js +474 -0
- package/dist/agent-harness.js.map +1 -0
- package/dist/challenge-detection.d.ts +17 -5
- package/dist/challenge-detection.js +65 -102
- package/dist/challenge-detection.js.map +1 -1
- package/dist/contracts.d.ts +4604 -326
- package/dist/contracts.js +232 -18
- package/dist/contracts.js.map +1 -1
- package/dist/harness-instructions.d.ts +7 -0
- package/dist/harness-instructions.js +28 -0
- package/dist/harness-instructions.js.map +1 -0
- package/dist/index.d.ts +105 -8
- package/dist/index.js +997 -32
- package/dist/index.js.map +1 -1
- package/dist/version.d.ts +4 -0
- package/dist/version.js +9 -0
- package/dist/version.js.map +1 -0
- package/package.json +11 -8
- package/dist/challenge-types.d.ts +0 -57
- package/dist/challenge-types.js +0 -111
- package/dist/challenge-types.js.map +0 -1
- package/dist/x402-v1.d.ts +0 -4
- package/dist/x402-v1.js +0 -53
- package/dist/x402-v1.js.map +0 -1
- package/dist/x402-v2.d.ts +0 -4
- package/dist/x402-v2.js +0 -52
- package/dist/x402-v2.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,6 +1,54 @@
|
|
|
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';
|
|
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
|
+
*/
|
|
17
|
+
export class FetchPaidError extends Error {
|
|
18
|
+
details;
|
|
19
|
+
kind;
|
|
20
|
+
protocol;
|
|
21
|
+
response;
|
|
22
|
+
reason;
|
|
23
|
+
decision;
|
|
24
|
+
paidRequestId;
|
|
25
|
+
paymentAttemptId;
|
|
26
|
+
receiptId;
|
|
27
|
+
receipt;
|
|
28
|
+
policyReviewEventId;
|
|
29
|
+
constructor(details) {
|
|
30
|
+
super(`${details.kind}: ${details.reason}`);
|
|
31
|
+
this.name = 'FetchPaidError';
|
|
32
|
+
this.details = details;
|
|
33
|
+
this.kind = details.kind;
|
|
34
|
+
this.protocol = details.protocol;
|
|
35
|
+
this.response = details.response;
|
|
36
|
+
this.reason = details.reason;
|
|
37
|
+
this.decision = details.decision;
|
|
38
|
+
this.paidRequestId = 'paidRequestId' in details ? details.paidRequestId : undefined;
|
|
39
|
+
this.paymentAttemptId =
|
|
40
|
+
'paymentAttemptId' in details ? details.paymentAttemptId : undefined;
|
|
41
|
+
this.receiptId = 'receiptId' in details ? details.receiptId : undefined;
|
|
42
|
+
this.receipt = 'receipt' in details ? details.receipt : undefined;
|
|
43
|
+
this.policyReviewEventId =
|
|
44
|
+
'policyReviewEventId' in details ? details.policyReviewEventId : undefined;
|
|
45
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** Type guard for callers that catch unknown errors around fetchPaid flows. */
|
|
49
|
+
export function isFetchPaidError(error) {
|
|
50
|
+
return error instanceof FetchPaidError;
|
|
51
|
+
}
|
|
4
52
|
const defaultRuntimeTokenRefreshWindowMs = 30_000;
|
|
5
53
|
function trimTrailingSlash(value) {
|
|
6
54
|
return value.endsWith('/') ? value.slice(0, -1) : value;
|
|
@@ -30,7 +78,93 @@ function createMerchantResponse(merchantResponse) {
|
|
|
30
78
|
headers: merchantResponse.headers,
|
|
31
79
|
});
|
|
32
80
|
}
|
|
33
|
-
function
|
|
81
|
+
function createRawResponse(status, body, headers) {
|
|
82
|
+
return new Response(body, {
|
|
83
|
+
status,
|
|
84
|
+
...(headers ? { headers } : {}),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
function isRecord(value) {
|
|
88
|
+
return typeof value === 'object' && value !== null;
|
|
89
|
+
}
|
|
90
|
+
function getControlPlaneErrorMessage(body, fallback) {
|
|
91
|
+
if (!isRecord(body)) {
|
|
92
|
+
return fallback;
|
|
93
|
+
}
|
|
94
|
+
const message = typeof body.message === 'string' && body.message.length > 0
|
|
95
|
+
? body.message
|
|
96
|
+
: fallback;
|
|
97
|
+
const issues = body.issues;
|
|
98
|
+
if (!isRecord(issues)) {
|
|
99
|
+
return message;
|
|
100
|
+
}
|
|
101
|
+
const details = [];
|
|
102
|
+
const formErrors = issues.formErrors;
|
|
103
|
+
if (Array.isArray(formErrors)) {
|
|
104
|
+
for (const entry of formErrors) {
|
|
105
|
+
if (typeof entry === 'string' && entry.length > 0) {
|
|
106
|
+
details.push(entry);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const fieldErrors = issues.fieldErrors;
|
|
111
|
+
if (isRecord(fieldErrors)) {
|
|
112
|
+
for (const [field, value] of Object.entries(fieldErrors)) {
|
|
113
|
+
if (!Array.isArray(value)) {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const fieldMessages = value.filter((entry) => typeof entry === 'string' && entry.length > 0);
|
|
117
|
+
if (fieldMessages.length > 0) {
|
|
118
|
+
details.push(`${field}: ${fieldMessages.join(', ')}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return details.length > 0 ? `${message} ${details.join(' ')}` : message;
|
|
123
|
+
}
|
|
124
|
+
async function readControlPlaneError(response, fallback) {
|
|
125
|
+
const responseBody = await response.text();
|
|
126
|
+
const parsedBody = tryParseJson(responseBody);
|
|
127
|
+
return {
|
|
128
|
+
responseBody,
|
|
129
|
+
parsedBody,
|
|
130
|
+
message: getControlPlaneErrorMessage(parsedBody, fallback),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
function tryParseJson(value) {
|
|
134
|
+
if (value.length === 0) {
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
return JSON.parse(value);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
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) {
|
|
34
168
|
if (body === undefined || body === null) {
|
|
35
169
|
return undefined;
|
|
36
170
|
}
|
|
@@ -40,7 +174,30 @@ function getReplayableRequestBody(body) {
|
|
|
40
174
|
if (body instanceof URLSearchParams) {
|
|
41
175
|
return body.toString();
|
|
42
176
|
}
|
|
43
|
-
throw new Error(
|
|
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);
|
|
44
201
|
}
|
|
45
202
|
function hashRequestBody(body) {
|
|
46
203
|
if (!body) {
|
|
@@ -65,9 +222,687 @@ function parseRuntimeTokenResponse(payload) {
|
|
|
65
222
|
expiresAt,
|
|
66
223
|
};
|
|
67
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
|
+
function buildPreparedChallengeAccept(candidate) {
|
|
818
|
+
const maxTimeoutSeconds = readIntegerValue(candidate.maxTimeoutSeconds);
|
|
819
|
+
return {
|
|
820
|
+
...(readStringValue(candidate.scheme)
|
|
821
|
+
? { scheme: readStringValue(candidate.scheme) }
|
|
822
|
+
: {}),
|
|
823
|
+
...(readStringValue(candidate.network)
|
|
824
|
+
? { network: readStringValue(candidate.network) }
|
|
825
|
+
: {}),
|
|
826
|
+
...(readStringValue(candidate.amount)
|
|
827
|
+
? { amount: readStringValue(candidate.amount) }
|
|
828
|
+
: {}),
|
|
829
|
+
...(readStringValue(candidate.maxAmountRequired)
|
|
830
|
+
? { maxAmountRequired: readStringValue(candidate.maxAmountRequired) }
|
|
831
|
+
: {}),
|
|
832
|
+
...(readStringValue(candidate.asset)
|
|
833
|
+
? { asset: readStringValue(candidate.asset) }
|
|
834
|
+
: {}),
|
|
835
|
+
...(readStringValue(candidate.payTo)
|
|
836
|
+
? { payTo: readStringValue(candidate.payTo) }
|
|
837
|
+
: {}),
|
|
838
|
+
...(maxTimeoutSeconds && maxTimeoutSeconds > 0
|
|
839
|
+
? { maxTimeoutSeconds }
|
|
840
|
+
: {}),
|
|
841
|
+
...(readStringValue(candidate.resource)
|
|
842
|
+
? { resource: readStringValue(candidate.resource) }
|
|
843
|
+
: {}),
|
|
844
|
+
...(readStringValue(candidate.description)
|
|
845
|
+
? { description: readStringValue(candidate.description) }
|
|
846
|
+
: {}),
|
|
847
|
+
...(readStringValue(candidate.mimeType)
|
|
848
|
+
? { mimeType: readStringValue(candidate.mimeType) }
|
|
849
|
+
: {}),
|
|
850
|
+
...(candidate.outputSchema !== undefined
|
|
851
|
+
? { outputSchema: candidate.outputSchema }
|
|
852
|
+
: {}),
|
|
853
|
+
...(isRecord(candidate.extra)
|
|
854
|
+
? { extra: candidate.extra }
|
|
855
|
+
: {}),
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
function buildPreparedChallengeDetails(challenge) {
|
|
859
|
+
if (challenge.protocol !== 'x402') {
|
|
860
|
+
return undefined;
|
|
861
|
+
}
|
|
862
|
+
const paymentRequiredPayload = tryParsePaymentRequiredHeader(challenge.headers['payment-required']);
|
|
863
|
+
const payload = unwrapChallengePayload(paymentRequiredPayload ?? challenge.body);
|
|
864
|
+
if (!isRecord(payload)) {
|
|
865
|
+
return undefined;
|
|
866
|
+
}
|
|
867
|
+
const resource = isRecord(payload.resource) ? payload.resource : undefined;
|
|
868
|
+
const accepts = Array.isArray(payload.accepts)
|
|
869
|
+
? payload.accepts.filter((candidate) => isRecord(candidate))
|
|
870
|
+
: [];
|
|
871
|
+
const x402Version = readIntegerValue(payload.x402Version);
|
|
872
|
+
return {
|
|
873
|
+
...(x402Version !== undefined ? { x402Version } : {}),
|
|
874
|
+
...(readStringValue(payload.error)
|
|
875
|
+
? { error: readStringValue(payload.error) }
|
|
876
|
+
: {}),
|
|
877
|
+
...(resource && readStringValue(resource.url)
|
|
878
|
+
? {
|
|
879
|
+
resource: {
|
|
880
|
+
url: readStringValue(resource.url),
|
|
881
|
+
...(readStringValue(resource.description)
|
|
882
|
+
? { description: readStringValue(resource.description) }
|
|
883
|
+
: {}),
|
|
884
|
+
...(readStringValue(resource.mimeType)
|
|
885
|
+
? { mimeType: readStringValue(resource.mimeType) }
|
|
886
|
+
: {}),
|
|
887
|
+
},
|
|
888
|
+
}
|
|
889
|
+
: {}),
|
|
890
|
+
accepts: accepts.map((candidate) => buildPreparedChallengeAccept(candidate)),
|
|
891
|
+
...(isRecord(payload.extensions)
|
|
892
|
+
? { extensions: payload.extensions }
|
|
893
|
+
: {}),
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
/**
|
|
897
|
+
* Client bound to one organization/agent identity and one 402flow control plane.
|
|
898
|
+
*
|
|
899
|
+
* Most integrations either call fetchPaid() directly or use the explicit
|
|
900
|
+
* preparePaidRequest() -> executePreparedRequest() flow.
|
|
901
|
+
*/
|
|
68
902
|
export class AgentPayClient {
|
|
69
903
|
controlPlaneBaseUrl;
|
|
70
904
|
auth;
|
|
905
|
+
identity;
|
|
71
906
|
fetchImpl;
|
|
72
907
|
headers;
|
|
73
908
|
cachedRuntimeToken;
|
|
@@ -75,11 +910,97 @@ export class AgentPayClient {
|
|
|
75
910
|
constructor(options) {
|
|
76
911
|
this.controlPlaneBaseUrl = trimTrailingSlash(options.controlPlaneBaseUrl);
|
|
77
912
|
this.auth = options.auth;
|
|
913
|
+
this.identity = {
|
|
914
|
+
organization: options.organization,
|
|
915
|
+
agent: options.agent,
|
|
916
|
+
};
|
|
78
917
|
this.fetchImpl = options.fetch ?? fetch;
|
|
79
918
|
this.headers = options.headers;
|
|
80
919
|
}
|
|
81
|
-
|
|
82
|
-
|
|
920
|
+
/**
|
|
921
|
+
* Probe or reuse a merchant challenge and return a normalized preparation
|
|
922
|
+
* result the caller can inspect before paying.
|
|
923
|
+
*/
|
|
924
|
+
async preparePaidRequest(input, init = {}, options = {}) {
|
|
925
|
+
const request = createPreparedHttpRequest(input, init);
|
|
926
|
+
if (options.challenge) {
|
|
927
|
+
const hints = await buildPreparedRequestHints(options, this.fetchImpl, options.challenge);
|
|
928
|
+
const validationIssues = buildPreparedValidationIssues(request, hints);
|
|
929
|
+
const paymentRequirement = buildPreparedPaymentRequirement(options.challenge);
|
|
930
|
+
const challengeDetails = buildPreparedChallengeDetails(options.challenge);
|
|
931
|
+
return {
|
|
932
|
+
kind: 'ready',
|
|
933
|
+
protocol: options.challenge.protocol,
|
|
934
|
+
request,
|
|
935
|
+
challenge: paidRequestChallengeSchema.parse(options.challenge),
|
|
936
|
+
...(challengeDetails ? { challengeDetails } : {}),
|
|
937
|
+
...(paymentRequirement
|
|
938
|
+
? {
|
|
939
|
+
paymentRequirement,
|
|
940
|
+
}
|
|
941
|
+
: {}),
|
|
942
|
+
hints,
|
|
943
|
+
validationIssues,
|
|
944
|
+
nextAction: derivePreparedNextAction('ready', validationIssues),
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
const initialResponse = await this.fetchImpl(input, init);
|
|
948
|
+
const challenge = await detectChallengeFromResponse(initialResponse);
|
|
949
|
+
const probe = {
|
|
950
|
+
responseStatus: initialResponse.status,
|
|
951
|
+
confirmedAt: new Date().toISOString(),
|
|
952
|
+
};
|
|
953
|
+
const hints = await buildPreparedRequestHints(options, this.fetchImpl, challenge);
|
|
954
|
+
const validationIssues = buildPreparedValidationIssues(request, hints);
|
|
955
|
+
if (!challenge) {
|
|
956
|
+
return {
|
|
957
|
+
kind: 'passthrough',
|
|
958
|
+
protocol: 'none',
|
|
959
|
+
request,
|
|
960
|
+
hints,
|
|
961
|
+
probe,
|
|
962
|
+
validationIssues,
|
|
963
|
+
nextAction: derivePreparedNextAction('passthrough', validationIssues),
|
|
964
|
+
};
|
|
965
|
+
}
|
|
966
|
+
const paymentRequirement = buildPreparedPaymentRequirement(challenge);
|
|
967
|
+
const challengeDetails = buildPreparedChallengeDetails(challenge);
|
|
968
|
+
return {
|
|
969
|
+
kind: 'ready',
|
|
970
|
+
protocol: challenge.protocol,
|
|
971
|
+
request,
|
|
972
|
+
challenge: paidRequestChallengeSchema.parse(challenge),
|
|
973
|
+
...(challengeDetails ? { challengeDetails } : {}),
|
|
974
|
+
...(paymentRequirement
|
|
975
|
+
? { paymentRequirement }
|
|
976
|
+
: {}),
|
|
977
|
+
hints,
|
|
978
|
+
probe,
|
|
979
|
+
validationIssues,
|
|
980
|
+
nextAction: derivePreparedNextAction('ready', validationIssues),
|
|
981
|
+
};
|
|
982
|
+
}
|
|
983
|
+
/**
|
|
984
|
+
* Execute the exact request that was previously prepared, without re-probing
|
|
985
|
+
* the merchant first.
|
|
986
|
+
*/
|
|
987
|
+
async executePreparedRequest(prepared, request = {}) {
|
|
988
|
+
return this.fetchPaid(prepared.request.url, {
|
|
989
|
+
method: prepared.request.method,
|
|
990
|
+
...(prepared.request.headers ? { headers: prepared.request.headers } : {}),
|
|
991
|
+
...(prepared.request.body !== undefined ? { body: prepared.request.body } : {}),
|
|
992
|
+
}, {
|
|
993
|
+
...request,
|
|
994
|
+
challenge: prepared.challenge,
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
/**
|
|
998
|
+
* Fast-path helper that probes when needed, asks the control plane for a paid
|
|
999
|
+
* execution decision, and returns either passthrough or success. All non-success
|
|
1000
|
+
* paid outcomes are thrown as FetchPaidError.
|
|
1001
|
+
*/
|
|
1002
|
+
async fetchPaid(input, init = {}, request) {
|
|
1003
|
+
let challenge = request.challenge;
|
|
83
1004
|
if (!challenge) {
|
|
84
1005
|
const initialResponse = await this.fetchImpl(input, init);
|
|
85
1006
|
challenge = await detectChallengeFromResponse(initialResponse);
|
|
@@ -91,41 +1012,41 @@ export class AgentPayClient {
|
|
|
91
1012
|
};
|
|
92
1013
|
}
|
|
93
1014
|
}
|
|
94
|
-
const decisionRequest =
|
|
95
|
-
const decision = await this.requestPaymentDecision(decisionRequest);
|
|
1015
|
+
const decisionRequest = this.createDecisionRequest(input, init, request, challenge);
|
|
1016
|
+
const decision = await this.requestPaymentDecision(decisionRequest, challenge.protocol);
|
|
96
1017
|
return this.mapDecisionToPaidResponse(decision, challenge.protocol);
|
|
97
1018
|
}
|
|
1019
|
+
/** Lookup a durable receipt by id through the control plane. */
|
|
98
1020
|
async lookupReceipt(receiptId) {
|
|
99
1021
|
const response = await this.controlPlaneFetch(`/api/sdk/receipts/${receiptId}`, {
|
|
100
1022
|
method: 'GET',
|
|
101
1023
|
}, await this.getRuntimeAuthorizationHeader());
|
|
102
1024
|
if (!response.ok) {
|
|
103
|
-
|
|
1025
|
+
const error = await readControlPlaneError(response, `Receipt lookup failed with status ${response.status}.`);
|
|
1026
|
+
throw new Error(error.message);
|
|
104
1027
|
}
|
|
105
1028
|
return sdkReceiptResponseSchema.parse(await response.json());
|
|
106
1029
|
}
|
|
107
|
-
|
|
108
|
-
const
|
|
1030
|
+
createDecisionRequest(input, init, request, challenge) {
|
|
1031
|
+
const { challenge: _challenge, idempotencyKey, ...requestContext } = request;
|
|
109
1032
|
return sdkPaymentDecisionRequestSchema.parse({
|
|
110
|
-
context
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
url: input,
|
|
114
|
-
method: (init.method ?? 'GET').toUpperCase(),
|
|
115
|
-
headers: normalizeHeaders(init.headers),
|
|
116
|
-
body: requestBody,
|
|
117
|
-
bodyHash: hashRequestBody(requestBody),
|
|
1033
|
+
context: {
|
|
1034
|
+
...this.identity,
|
|
1035
|
+
...requestContext,
|
|
118
1036
|
},
|
|
1037
|
+
request: createPreparedHttpRequest(input, init),
|
|
119
1038
|
challenge: {
|
|
120
1039
|
protocol: challenge.protocol,
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
...(challenge.payee ? { payee: challenge.payee } : {}),
|
|
1040
|
+
headers: challenge.headers,
|
|
1041
|
+
...(challenge.body !== undefined ? { body: challenge.body } : {}),
|
|
124
1042
|
},
|
|
125
|
-
idempotencyKey
|
|
1043
|
+
idempotencyKey,
|
|
126
1044
|
});
|
|
127
1045
|
}
|
|
128
|
-
async requestPaymentDecision(decisionRequest) {
|
|
1046
|
+
async requestPaymentDecision(decisionRequest, protocol) {
|
|
1047
|
+
// The control plane is the single source of truth for payment policy and
|
|
1048
|
+
// receipt persistence. Any response that does not match the SDK contract is
|
|
1049
|
+
// downgraded to request_failed.
|
|
129
1050
|
const decisionResponse = await this.controlPlaneFetch('/api/sdk/payment-decisions', {
|
|
130
1051
|
method: 'POST',
|
|
131
1052
|
headers: {
|
|
@@ -133,12 +1054,46 @@ export class AgentPayClient {
|
|
|
133
1054
|
},
|
|
134
1055
|
body: JSON.stringify(decisionRequest),
|
|
135
1056
|
}, await this.getRuntimeAuthorizationHeader());
|
|
1057
|
+
const responseBody = await decisionResponse.text();
|
|
1058
|
+
const parsedBody = tryParseJson(responseBody);
|
|
1059
|
+
if (parsedBody !== undefined) {
|
|
1060
|
+
try {
|
|
1061
|
+
return sdkPaymentDecisionResponseSchema.parse(parsedBody);
|
|
1062
|
+
}
|
|
1063
|
+
catch {
|
|
1064
|
+
// Fall through to request-failed handling below.
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
136
1067
|
if (!decisionResponse.ok) {
|
|
137
|
-
throw new
|
|
1068
|
+
throw new FetchPaidError({
|
|
1069
|
+
kind: 'request_failed',
|
|
1070
|
+
protocol,
|
|
1071
|
+
response: createRawResponse(decisionResponse.status, responseBody, decisionResponse.headers),
|
|
1072
|
+
reason: getControlPlaneErrorMessage(parsedBody, `Payment decision failed with status ${decisionResponse.status}.`),
|
|
1073
|
+
decision: {
|
|
1074
|
+
outcome: 'request_failed',
|
|
1075
|
+
status: decisionResponse.status,
|
|
1076
|
+
message: getControlPlaneErrorMessage(parsedBody, `Payment decision failed with status ${decisionResponse.status}.`),
|
|
1077
|
+
...(parsedBody !== undefined ? { body: parsedBody } : {}),
|
|
1078
|
+
},
|
|
1079
|
+
});
|
|
138
1080
|
}
|
|
139
|
-
|
|
1081
|
+
throw new FetchPaidError({
|
|
1082
|
+
kind: 'request_failed',
|
|
1083
|
+
protocol,
|
|
1084
|
+
response: createRawResponse(decisionResponse.status, responseBody, decisionResponse.headers),
|
|
1085
|
+
reason: 'Payment decision response did not match the SDK contract.',
|
|
1086
|
+
decision: {
|
|
1087
|
+
outcome: 'request_failed',
|
|
1088
|
+
status: decisionResponse.status,
|
|
1089
|
+
message: 'Payment decision response did not match the SDK contract.',
|
|
1090
|
+
...(parsedBody !== undefined ? { body: parsedBody } : {}),
|
|
1091
|
+
},
|
|
1092
|
+
});
|
|
140
1093
|
}
|
|
141
1094
|
mapDecisionToPaidResponse(decision, protocol) {
|
|
1095
|
+
// Only allow returns normally. Every other durable or non-durable paid outcome
|
|
1096
|
+
// is surfaced as a typed FetchPaidError so caller control flow stays explicit.
|
|
142
1097
|
switch (decision.outcome) {
|
|
143
1098
|
case 'allow': {
|
|
144
1099
|
const response = {
|
|
@@ -164,7 +1119,7 @@ export class AgentPayClient {
|
|
|
164
1119
|
reason: decision.reason,
|
|
165
1120
|
decision,
|
|
166
1121
|
};
|
|
167
|
-
|
|
1122
|
+
throw new FetchPaidError(response);
|
|
168
1123
|
}
|
|
169
1124
|
case 'deny': {
|
|
170
1125
|
const response = {
|
|
@@ -180,7 +1135,7 @@ export class AgentPayClient {
|
|
|
180
1135
|
? { policyReviewEventId: decision.policyReviewEventId }
|
|
181
1136
|
: {}),
|
|
182
1137
|
};
|
|
183
|
-
|
|
1138
|
+
throw new FetchPaidError(response);
|
|
184
1139
|
}
|
|
185
1140
|
case 'executing': {
|
|
186
1141
|
const response = {
|
|
@@ -192,7 +1147,7 @@ export class AgentPayClient {
|
|
|
192
1147
|
reason: decision.reason,
|
|
193
1148
|
decision,
|
|
194
1149
|
};
|
|
195
|
-
|
|
1150
|
+
throw new FetchPaidError(response);
|
|
196
1151
|
}
|
|
197
1152
|
case 'inconclusive': {
|
|
198
1153
|
const response = {
|
|
@@ -204,7 +1159,7 @@ export class AgentPayClient {
|
|
|
204
1159
|
reason: decision.reason,
|
|
205
1160
|
decision,
|
|
206
1161
|
};
|
|
207
|
-
|
|
1162
|
+
throw new FetchPaidError(response);
|
|
208
1163
|
}
|
|
209
1164
|
case 'execution_failed': {
|
|
210
1165
|
const response = {
|
|
@@ -216,7 +1171,7 @@ export class AgentPayClient {
|
|
|
216
1171
|
reason: decision.reason,
|
|
217
1172
|
decision,
|
|
218
1173
|
};
|
|
219
|
-
|
|
1174
|
+
throw new FetchPaidError(response);
|
|
220
1175
|
}
|
|
221
1176
|
case 'preflight_failed': {
|
|
222
1177
|
const response = {
|
|
@@ -228,7 +1183,7 @@ export class AgentPayClient {
|
|
|
228
1183
|
reason: decision.reason,
|
|
229
1184
|
decision,
|
|
230
1185
|
};
|
|
231
|
-
|
|
1186
|
+
throw new FetchPaidError(response);
|
|
232
1187
|
}
|
|
233
1188
|
}
|
|
234
1189
|
}
|
|
@@ -264,7 +1219,8 @@ export class AgentPayClient {
|
|
|
264
1219
|
method: 'POST',
|
|
265
1220
|
}, `Bearer ${this.auth.bootstrapKey}`);
|
|
266
1221
|
if (!response.ok) {
|
|
267
|
-
|
|
1222
|
+
const error = await readControlPlaneError(response, `Runtime token exchange failed with status ${response.status}.`);
|
|
1223
|
+
throw new Error(error.message);
|
|
268
1224
|
}
|
|
269
1225
|
const runtimeToken = parseRuntimeTokenResponse(await response.json());
|
|
270
1226
|
this.cachedRuntimeToken = {
|
|
@@ -274,17 +1230,26 @@ export class AgentPayClient {
|
|
|
274
1230
|
return runtimeToken.token;
|
|
275
1231
|
}
|
|
276
1232
|
async controlPlaneFetch(path, init, authorizationHeader) {
|
|
1233
|
+
// Every control-plane call carries the SDK version header so incompatible
|
|
1234
|
+
// client/server contract mismatches can fail fast.
|
|
277
1235
|
return this.fetchImpl(`${this.controlPlaneBaseUrl}${path}`, {
|
|
278
1236
|
...init,
|
|
279
1237
|
headers: {
|
|
280
1238
|
...(this.headers ?? {}),
|
|
281
1239
|
...(normalizeHeaders(init.headers) ?? {}),
|
|
282
1240
|
Authorization: authorizationHeader,
|
|
1241
|
+
[sdkClientVersionHeaderName]: sdkClientVersion,
|
|
283
1242
|
},
|
|
284
1243
|
});
|
|
285
1244
|
}
|
|
286
1245
|
}
|
|
1246
|
+
/** Small factory wrapper for callers that prefer a function export. */
|
|
287
1247
|
export function createAgentPayClient(options) {
|
|
288
1248
|
return new AgentPayClient(options);
|
|
289
1249
|
}
|
|
1250
|
+
export * from './agent-harness.js';
|
|
1251
|
+
export * from './contracts.js';
|
|
1252
|
+
export * from './harness-instructions.js';
|
|
1253
|
+
export { detectChallengeFromResponse } from './challenge-detection.js';
|
|
1254
|
+
export { sdkClientVersion, sdkClientVersionHeaderName } from './version.js';
|
|
290
1255
|
//# sourceMappingURL=index.js.map
|