diff --git a/AGENTS.md b/AGENTS.md index 9d1253e..bd47220 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -130,6 +130,12 @@ const quote = paidRoute(dual, { }, required: ["symbol", "price"], }, + // Optional. dual402 can synthesize simple Bazaar examples from schemas when + // omitted; set these when a realistic marketplace example matters. + bazaar: { + inputExample: { symbol: "ETH" }, + outputExample: { symbol: "ETH", price: 42 }, + }, }); function validateQuoteRequest(req, res, next) { @@ -157,6 +163,15 @@ dualDiscovery(app, dual, { human-readable label that ends up in the MPP `WWW-Authenticate` header. If omitted, dual402 reuses `summary`. +`dualDiscovery` also binds Bazaar metadata to CDP verify/settle payloads. The +generated `extensions.bazaar.info` is built with a matching Draft 2020-12 schema; +use explicit `bazaar` examples for marketplace-facing routes with complex JSON +Schemas. `serviceName` / `tags` / `iconUrl` are sanitized to the Bazaar +service-metadata limits before being advertised. If the facilitator returns an +`EXTENSION-RESPONSES` header, dual402 copies it into the `PAYMENT-RESPONSE` +receipt as `extensionResponses` so you can see Bazaar `processing` / `rejected` +statuses while testing. + ## Middleware Ordering Place validation that should not require payment **before** `route.handler`, diff --git a/README.md b/README.md index 19e9015..7c97a33 100644 --- a/README.md +++ b/README.md @@ -145,10 +145,17 @@ CDP credentials. - `createDual402(config)` validates shared x402 and MPP configuration. - `paidRoute(dual, options)` creates route middleware and discovery metadata. + Set `bazaar.inputExample`, `bazaar.outputExample`, or `bazaar.bodyType` when + you want Bazaar marketplace metadata to use real examples. dual402 can + synthesize best-effort examples from simple request/response JSON Schemas, but + marketplace-facing routes should pass explicit examples for complex schemas. - `dualDiscovery(app, dual, config)` mounts `GET /openapi.json` and `GET /.well-known/x402`. Set `serviceName`, `tags`, and `iconUrl` to surface Bazaar-style identity metadata in the x402 `resource` object and discovery - manifest. + manifest. These fields are sanitized to the Bazaar service-metadata limits + before they are sent to the x402 facilitator. When the facilitator returns + `EXTENSION-RESPONSES`, dual402 includes it in the `PAYMENT-RESPONSE` receipt + as `extensionResponses` for Bazaar indexing diagnostics. - `dual.charge({ amount, description?, waitForSettle? })` is the lower-level middleware factory when you do not need discovery metadata. diff --git a/src/charge.ts b/src/charge.ts index fda3e2f..30eff29 100644 --- a/src/charge.ts +++ b/src/charge.ts @@ -14,6 +14,7 @@ import { x402Verify, type JsonObject, type JsonSchema, + type BazaarRouteMetadata, } from "./internal/x402.js"; import type { ChargeOptions, DualChargeHandler, OnVerify, ResolvedX402Config } from "./types.js"; @@ -41,7 +42,12 @@ export function createChargeFactory({ (typeof req.path === "string" && req.path) || String(req.originalUrl || "").split("?")[0] || "/"; - const method = String(req.method || "GET").toUpperCase(); + const method = String( + handler._dualCanonicalMethodsByPath?.[route] ?? + handler._dualCanonicalMethod ?? + req.method ?? + "GET", + ).toUpperCase(); const routeKey = `${method} ${route}`; const inputSchema = handler._dualInputSchemasByRoute?.[routeKey] ?? @@ -49,6 +55,10 @@ export function createChargeFactory({ const outputSchema = handler._dualOutputSchemasByRoute?.[routeKey] ?? handler._dualOutputSchemasByMethod?.[method] ?? handler._dualOutputSchema; + const bazaarMetadata = + handler._dualBazaarByRoute?.[routeKey] ?? + handler._dualBazaarByMethod?.[method] ?? + handler._dualBazaar; const tags = handler._dualTagsByRoute?.[routeKey] ?? handler._dualTags; const resourceUrl = `${resolveBaseUrl(req)}${route}`; const resource = buildPaymentResourceInfo({ @@ -58,7 +68,12 @@ export function createChargeFactory({ tags, iconUrl: handler._dualIconUrl, }); - const extensions = buildBazaarExtensions({ method, inputSchema, outputSchema }); + const extensions = buildBazaarExtensions({ + method, + inputSchema, + outputSchema, + ...bazaarMetadata, + }); try { const x402Sig = readPaymentSignature(req); @@ -97,12 +112,18 @@ export function createChargeFactory({ x402Config.timeoutMs, verified.paymentRequirements ?? paymentRequirements, x402Config.cdpAuth, + { resource, extensions }, ); if (waitForSettle) { try { const result = await settlePromise; - applyReceiptHeader(res, x402Config.network, result.txHash); + applyReceiptHeader( + res, + x402Config.network, + result.txHash, + result.extensionResponses ?? verified.extensionResponses, + ); logSettle(amount, route, result.txHash); } catch (error) { console.error( @@ -119,6 +140,8 @@ export function createChargeFactory({ extra: x402Config.extra, inputSchema, outputSchema, + bazaarMetadata, + method, serviceName: handler._dualServiceName, tags, iconUrl: handler._dualIconUrl, @@ -136,7 +159,12 @@ export function createChargeFactory({ `[PAY] x402 settle FAILED amount=${amount} route=${route} err=${errorMessage(error)}`, ); }); - applyReceiptHeader(res, x402Config.network, verified.txHash); + applyReceiptHeader( + res, + x402Config.network, + verified.txHash, + verified.extensionResponses, + ); } return next(); @@ -159,6 +187,7 @@ export function createChargeFactory({ extra: x402Config.extra, inputSchema, outputSchema, + ...bazaarMetadata, serviceName: handler._dualServiceName, tags, iconUrl: handler._dualIconUrl, @@ -229,6 +258,7 @@ function applyReceiptHeader( res: Response, network: string, txHash: string | undefined, + extensionResponses?: JsonObject, ): void { if (txHash && !res.headersSent) { res.setHeader( @@ -238,6 +268,7 @@ function applyReceiptHeader( success: true, txHash, network, + ...(extensionResponses && { extensionResponses }), }), ).toString("base64"), ); @@ -257,6 +288,8 @@ function attachFallbackPaymentRequired( extra: { name: string; version: string }; inputSchema?: JsonSchema; outputSchema?: JsonSchema; + bazaarMetadata?: BazaarRouteMetadata; + method: string; serviceName?: string; tags?: string[]; iconUrl?: string; @@ -278,10 +311,11 @@ function attachFallbackPaymentRequired( extra: args.extra, inputSchema: args.inputSchema, outputSchema: args.outputSchema, + ...args.bazaarMetadata, serviceName: args.serviceName, tags: args.tags, iconUrl: args.iconUrl, - method: args.req.method, + method: args.method, }), ), ).toString("base64"), diff --git a/src/discovery.ts b/src/discovery.ts index 541c198..7349ad6 100644 --- a/src/discovery.ts +++ b/src/discovery.ts @@ -6,7 +6,9 @@ import { extractRequestBodySchema, parametersToSchema, resolveBaseUrl, + sanitizeResourceServiceMetadata, toSmallestUnit, + type BazaarRouteMetadata, type JsonObject, type JsonSchema, } from "./internal/x402.js"; @@ -30,11 +32,9 @@ const OPENAPI_HTTP_METHODS = new Set([ "DELETE", "GET", "HEAD", - "OPTIONS", "PATCH", "POST", "PUT", - "TRACE", ]); /** @@ -130,6 +130,10 @@ export function dualDiscovery( extractRequestBodySchema(requestBody) ?? parametersToSchema(route.parameters); const outputSchema = route.responseSchema ?? DEFAULT_RESPONSE_SCHEMA; + const bazaarMetadata = normalizeBazaarMetadata(route.bazaar); + route.handler._dualCanonicalMethod ??= method; + route.handler._dualCanonicalMethodsByPath ??= {}; + route.handler._dualCanonicalMethodsByPath[route.path] = method; if (inputSchema) { route.handler._dualInputSchema ??= inputSchema; route.handler._dualInputSchemasByMethod ??= {}; @@ -142,6 +146,13 @@ export function dualDiscovery( route.handler._dualOutputSchemasByMethod[method] = outputSchema; route.handler._dualOutputSchemasByRoute ??= {}; route.handler._dualOutputSchemasByRoute[`${method} ${route.path}`] = outputSchema; + if (bazaarMetadata) { + route.handler._dualBazaar ??= bazaarMetadata; + route.handler._dualBazaarByMethod ??= {}; + route.handler._dualBazaarByMethod[method] = bazaarMetadata; + route.handler._dualBazaarByRoute ??= {}; + route.handler._dualBazaarByRoute[`${method} ${route.path}`] = bazaarMetadata; + } if (serviceName) route.handler._dualServiceName = serviceName; const routeTags = mergeTags(serviceTags, route.tags); if (routeTags.length > 0) { @@ -271,9 +282,24 @@ function buildX402Resource(args: { route.handler._dualOutputSchemasByRoute?.[routeKey] ?? route.handler._dualOutputSchemasByMethod?.[method] ?? route.handler._dualOutputSchema; - const extensions = buildBazaarExtensions({ method, inputSchema, outputSchema }); + const bazaarMetadata = + route.handler._dualBazaarByRoute?.[routeKey] ?? + route.handler._dualBazaarByMethod?.[method] ?? + route.handler._dualBazaar ?? + normalizeBazaarMetadata(route.bazaar); + const extensions = buildBazaarExtensions({ + method, + inputSchema, + outputSchema, + ...bazaarMetadata, + }); const serviceName = config.serviceName ?? config.info?.title; const tags = mergeTags(config.tags, route.tags); + const serviceMetadata = sanitizeResourceServiceMetadata({ + serviceName, + tags, + iconUrl: config.iconUrl, + }); return { resource: resourceUrl, @@ -293,9 +319,7 @@ function buildX402Resource(args: { }), ], ...(extensions && { extensions }), - ...(serviceName && { serviceName }), - ...(tags.length > 0 && { tags }), - ...(config.iconUrl && { iconUrl: config.iconUrl }), + ...serviceMetadata, }; } @@ -351,6 +375,23 @@ function mergeTags( return normalizeTags([...(first ?? []), ...(second ?? [])]); } +function normalizeBazaarMetadata( + metadata: BazaarRouteMetadata | undefined, +): BazaarRouteMetadata | undefined { + if (!metadata || typeof metadata !== "object") return undefined; + const normalized: BazaarRouteMetadata = {}; + if ("inputExample" in metadata) normalized.inputExample = metadata.inputExample; + if ("outputExample" in metadata) normalized.outputExample = metadata.outputExample; + if ( + metadata.bodyType === "json" || + metadata.bodyType === "form-data" || + metadata.bodyType === "text" + ) { + normalized.bodyType = metadata.bodyType; + } + return Object.keys(normalized).length > 0 ? normalized : undefined; +} + function assertDiscoveryRoute( route: | { diff --git a/src/express.ts b/src/express.ts index 86e7d30..b87c91a 100644 --- a/src/express.ts +++ b/src/express.ts @@ -25,6 +25,8 @@ export { maskHex } from "./internal/x402.js"; export { parseCdpPrivateKey } from "./internal/cdp.js"; export type { CdpAuth, + BazaarBodyType, + BazaarRouteMetadata, ChargeOptions, DiscoveryConfig, DiscoveryRoute, diff --git a/src/internal/x402.ts b/src/internal/x402.ts index a6cafb2..2aee437 100644 --- a/src/internal/x402.ts +++ b/src/internal/x402.ts @@ -1,4 +1,5 @@ import type { Request, Response } from "express"; +import { domainToASCII } from "node:url"; import { generateCdpJwt } from "./cdp.js"; @@ -27,12 +28,21 @@ export type PaymentResourceInfo = { iconUrl?: string; }; +export type BazaarBodyType = "json" | "form-data" | "text"; + +export type BazaarRouteMetadata = { + inputExample?: unknown; + outputExample?: unknown; + bodyType?: BazaarBodyType; +}; + export type VerifyResult = { valid: boolean; reason?: string; txHash?: string; payload?: JsonObject; paymentRequirements?: JsonObject; + extensionResponses?: JsonObject; }; type CdpAuthLike = Readonly<{ @@ -44,6 +54,22 @@ const MAX_SIGNATURE_BYTES = 16 * 1024; const CDP_FACILITATOR_HOST = "api.cdp.coinbase.com"; const BAZAAR_QUERY_METHODS = ["GET", "HEAD", "DELETE"]; const BAZAAR_BODY_METHODS = ["POST", "PUT", "PATCH"]; +const MAX_SERVICE_NAME_LENGTH = 32; +const MAX_TAG_LENGTH = 32; +const MAX_TAGS = 5; +const MAX_ICON_URL_LENGTH = 2048; +const CONTROL_CHAR_RE = /[\x00-\x1f\x7f]/; +const PRINTABLE_ASCII_RE = /^[\x20-\x7e]+$/; +const UNICODE_CONTROL_RE = /\p{Cc}/u; +const IPV4_RE = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/; +const ALL_DIGITS_RE = /^\d+$/; +const HEX_LITERAL_RE = /^0x[0-9a-f]+$/i; +const LOOPBACK_HOSTNAMES = new Set([ + "localhost", + "localhost.localdomain", + "ip6-localhost", + "ip6-loopback", +]); function base64Json(data: unknown): string { return Buffer.from(JSON.stringify(data)).toString("base64"); @@ -172,17 +198,21 @@ export function buildAcceptsEntry(args: { export function buildPaymentResourceInfo(args: { resourceUrl?: string; description?: string; - serviceName?: string; - tags?: string[]; - iconUrl?: string; + serviceName?: unknown; + tags?: unknown; + iconUrl?: unknown; }): PaymentResourceInfo { + const serviceMetadata = sanitizeResourceServiceMetadata({ + serviceName: args.serviceName, + tags: args.tags, + iconUrl: args.iconUrl, + }); + return { url: args.resourceUrl ?? "", ...(args.description && { description: args.description }), mimeType: "application/json", - ...(args.serviceName && { serviceName: args.serviceName }), - ...(args.tags?.length && { tags: args.tags }), - ...(args.iconUrl && { iconUrl: args.iconUrl }), + ...serviceMetadata, }; } @@ -196,6 +226,9 @@ export function buildPaymentRequired(args: { extra: { name: string; version: string }; inputSchema?: JsonSchema; outputSchema?: JsonSchema; + inputExample?: unknown; + outputExample?: unknown; + bodyType?: BazaarBodyType; method?: string; serviceName?: string; tags?: string[]; @@ -206,6 +239,9 @@ export function buildPaymentRequired(args: { method, inputSchema: args.inputSchema, outputSchema: args.outputSchema, + inputExample: args.inputExample, + outputExample: args.outputExample, + bodyType: args.bodyType, }); return { @@ -236,49 +272,71 @@ export function buildBazaarExtensions(args: { method?: string; inputSchema?: JsonSchema; outputSchema?: JsonSchema; + inputExample?: unknown; + outputExample?: unknown; + bodyType?: BazaarBodyType; }): JsonObject | undefined { const { method = "", inputSchema, outputSchema } = args; - const hasInput = !!inputSchema; - const hasOutput = !!outputSchema; + const hasInput = !!inputSchema || args.inputExample !== undefined; + const hasOutput = !!outputSchema || args.outputExample !== undefined; if (!hasInput && !hasOutput) return undefined; const upper = method.toUpperCase(); const isBodyMethod = BAZAAR_BODY_METHODS.includes(upper); const isQueryMethod = BAZAAR_QUERY_METHODS.includes(upper); + if (!isBodyMethod && !isQueryMethod) return undefined; + + const bodyType = args.bodyType ?? "json"; + const inputExample = + args.inputExample !== undefined + ? args.inputExample + : inputSchema + ? exampleFromJsonSchema(inputSchema) + : {}; + const outputExample = + args.outputExample !== undefined + ? args.outputExample + : outputSchema + ? exampleFromJsonSchema(outputSchema) + : undefined; const infoInput: JsonObject = { type: "http", ...(upper && { method: upper }), - ...(isBodyMethod && { bodyType: "json", body: {} }), - ...(!isBodyMethod && hasInput && { queryParams: {} }), + ...(isBodyMethod && { bodyType, body: inputExample ?? {} }), + ...(!isBodyMethod && hasInput && { queryParams: inputExample ?? {} }), }; const info: JsonObject = { input: infoInput, - ...(hasOutput && { output: { type: "json", example: {} } }), + ...(hasOutput && { + output: { + type: "json", + ...(outputExample !== undefined && { example: outputExample }), + }, + }), }; + const methodEnum = upper + ? [upper] + : isBodyMethod + ? BAZAAR_BODY_METHODS + : BAZAAR_QUERY_METHODS; const inputProperties: JsonObject = { type: { type: "string", const: "http" }, - ...(upper && { - method: { - type: "string", - enum: isBodyMethod - ? BAZAAR_BODY_METHODS - : isQueryMethod - ? BAZAAR_QUERY_METHODS - : [upper], - }, - }), + method: { + type: "string", + enum: methodEnum, + }, ...(isBodyMethod && { bodyType: { type: "string", enum: ["json", "form-data", "text"] }, - body: inputSchema ?? { type: "object" }, + body: inputSchema ?? schemaFromExample(inputExample) ?? { type: "object" }, }), ...(!isBodyMethod && hasInput && { queryParams: { type: "object", - ...inputSchema, + ...(inputSchema ?? schemaFromExample(inputExample)), }, }), }; @@ -290,7 +348,9 @@ export function buildBazaarExtensions(args: { input: { type: "object", properties: inputProperties, - required: isBodyMethod ? ["type", "bodyType", "body"] : ["type"], + required: isBodyMethod + ? ["type", "method", "bodyType", "body"] + : ["type", "method"], additionalProperties: false, }, ...(hasOutput && { @@ -299,8 +359,10 @@ export function buildBazaarExtensions(args: { properties: { type: { type: "string" }, example: { - type: "object", - ...outputSchema, + ...(outputSchema ?? + schemaFromExample(outputExample) ?? { + type: "object", + }), }, }, required: ["type"], @@ -313,6 +375,163 @@ export function buildBazaarExtensions(args: { return { bazaar: { info, schema } }; } +export function sanitizeResourceServiceMetadata(args: { + serviceName?: unknown; + tags?: unknown; + iconUrl?: unknown; +}): Pick { + const out: Pick = {}; + if (isValidServiceName(args.serviceName)) out.serviceName = args.serviceName; + const tags = sanitizeTags(args.tags); + if (tags) out.tags = tags; + if (isValidIconUrl(args.iconUrl)) out.iconUrl = args.iconUrl; + return out; +} + +function isValidServiceName(value: unknown): value is string { + if (typeof value !== "string") return false; + if (value.length === 0 || value.length > MAX_SERVICE_NAME_LENGTH) return false; + if (UNICODE_CONTROL_RE.test(value)) return false; + return PRINTABLE_ASCII_RE.test(value); +} + +function sanitizeTags(value: unknown): string[] | undefined { + if (!Array.isArray(value)) return undefined; + const out: string[] = []; + const seen = new Set(); + + for (const entry of value) { + if (typeof entry !== "string") continue; + if (entry.length === 0 || entry.length > MAX_TAG_LENGTH) continue; + if (UNICODE_CONTROL_RE.test(entry) || !PRINTABLE_ASCII_RE.test(entry)) continue; + const key = entry.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + out.push(entry); + if (out.length === MAX_TAGS) break; + } + + return out.length > 0 ? out : undefined; +} + +function isValidIconUrl(value: unknown): value is string { + if (typeof value !== "string") return false; + if (value.length === 0 || value.length > MAX_ICON_URL_LENGTH) return false; + if (CONTROL_CHAR_RE.test(value)) return false; + + let parsed: URL; + try { + parsed = new URL(value); + } catch { + return false; + } + + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false; + if (parsed.username !== "" || parsed.password !== "") return false; + if (parsed.host.startsWith("[")) return false; + + let hostname: string; + try { + hostname = decodeURIComponent(parsed.hostname); + } catch { + return false; + } + + hostname = domainToASCII(hostname).toLowerCase(); + if (!hostname) return false; + if (LOOPBACK_HOSTNAMES.has(hostname)) return false; + if (IPV4_RE.test(hostname)) return false; + if (ALL_DIGITS_RE.test(hostname)) return false; + if (HEX_LITERAL_RE.test(hostname)) return false; + return true; +} + +function exampleFromJsonSchema(schema: JsonSchema | undefined): unknown { + const object = asObject(schema); + if (!object) return {}; + if ("const" in object) return object.const; + if (Array.isArray(object.enum) && object.enum.length > 0) return object.enum[0]; + if ("default" in object) return object.default; + if (Array.isArray(object.examples) && object.examples.length > 0) { + return object.examples[0]; + } + + const rawType = object.type; + const type = Array.isArray(rawType) + ? rawType.find((value) => value !== "null") + : rawType; + + if (type === "object" || asObject(object.properties)) { + const properties = asObject(object.properties) ?? {}; + const out: JsonObject = {}; + for (const [key, value] of Object.entries(properties)) { + out[key] = exampleFromJsonSchema(asObject(value) ?? undefined); + } + return out; + } + + if (type === "array") return []; + if (type === "integer") return exampleNumber(object, true); + if (type === "number") return exampleNumber(object, false); + if (type === "boolean") return true; + if (type === "null") return null; + if (type === "string") return exampleString(object); + return {}; +} + +function exampleNumber(schema: JsonObject, integer: boolean): number { + const minimum = + typeof schema.minimum === "number" + ? schema.minimum + : typeof schema.exclusiveMinimum === "number" + ? schema.exclusiveMinimum + 1 + : 0; + return integer ? Math.ceil(minimum) : minimum; +} + +function exampleString(schema: JsonObject): string { + if (schema.format === "date-time") return "2026-01-01T00:00:00.000Z"; + if (schema.format === "date") return "2026-01-01"; + if (schema.format === "uri" || schema.format === "url") { + return "https://example.com"; + } + + const minLength = + typeof schema.minLength === "number" && schema.minLength > 0 + ? schema.minLength + : 1; + const maxLength = + typeof schema.maxLength === "number" && schema.maxLength > 0 + ? schema.maxLength + : undefined; + const length = maxLength ? Math.min(minLength, maxLength) : minLength; + return "x".repeat(length); +} + +function schemaFromExample(example: unknown): JsonSchema | undefined { + if (example === undefined) return undefined; + if (example === null) return { type: "null" }; + if (Array.isArray(example)) { + return { + type: "array", + ...(example.length > 0 && { items: schemaFromExample(example[0]) }), + }; + } + if (typeof example === "object") { + const properties: JsonObject = {}; + for (const [key, value] of Object.entries(example as JsonObject)) { + properties[key] = schemaFromExample(value) ?? {}; + } + return { type: "object", properties }; + } + if (typeof example === "string") return { type: "string" }; + if (typeof example === "number") { + return { type: Number.isInteger(example) ? "integer" : "number" }; + } + if (typeof example === "boolean") return { type: "boolean" }; + return undefined; +} + export function patchStatusToInject402( res: Response, paymentRequired: JsonObject, @@ -448,29 +667,26 @@ function normalizeResourceInfo( value: unknown, fallbackResource?: PaymentResourceInfo, ): PaymentResourceInfo | undefined { - if (fallbackResource) return { ...fallbackResource }; - - if (typeof value === "string" && value.length > 0) { - return buildPaymentResourceInfo({ resourceUrl: value }); - } - const resource = asObject(value); - if (!resource || typeof resource.url !== "string" || resource.url.length === 0) { - return undefined; - } + const normalized = + typeof value === "string" && value.length > 0 + ? buildPaymentResourceInfo({ resourceUrl: value }) + : resource && typeof resource.url === "string" && resource.url.length > 0 + ? buildPaymentResourceInfo({ + resourceUrl: resource.url, + description: + typeof resource.description === "string" ? resource.description : undefined, + serviceName: resource.serviceName, + tags: Array.isArray(resource.tags) ? resource.tags : undefined, + iconUrl: resource.iconUrl, + }) + : undefined; + + if (!fallbackResource) return normalized; return { - url: resource.url, - ...(typeof resource.description === "string" && - resource.description.length > 0 && { description: resource.description }), - ...(typeof resource.mimeType === "string" && - resource.mimeType.length > 0 && { mimeType: resource.mimeType }), - ...(typeof resource.serviceName === "string" && - resource.serviceName.length > 0 && { serviceName: resource.serviceName }), - ...(Array.isArray(resource.tags) && - resource.tags.every((tag) => typeof tag === "string") && { tags: resource.tags }), - ...(typeof resource.iconUrl === "string" && - resource.iconUrl.length > 0 && { iconUrl: resource.iconUrl }), + ...fallbackResource, + mimeType: fallbackResource.mimeType ?? "application/json", }; } @@ -479,9 +695,20 @@ function mergeExtensions( serverExtensions: JsonObject | undefined, ): JsonObject | undefined { const client = asObject(clientExtensions); - if (!client && !serverExtensions) return undefined; + const sanitizedClient = client ? { ...client } : undefined; + delete sanitizedClient?.bazaar; + + if (!sanitizedClient && !serverExtensions) return undefined; + if ( + sanitizedClient && + Object.keys(sanitizedClient).length === 0 && + !serverExtensions + ) { + return undefined; + } + return { - ...(client ?? {}), + ...(sanitizedClient ?? {}), ...(serverExtensions ?? {}), }; } @@ -509,6 +736,8 @@ function canonicalizePaymentPayload( const extensions = mergeExtensions(next.extensions, options.serverExtensions); if (extensions) { next.extensions = extensions; + } else { + delete next.extensions; } return next; @@ -637,6 +866,7 @@ export async function x402Verify( if (!data) { return { valid: false, reason: "facilitator_bad_json" }; } + const extensionResponses = parseExtensionResponses(res.headers); const valid = data.isValid === true || data.valid === true; const rawReason = @@ -664,6 +894,7 @@ export async function x402Verify( txHash: typeof data.txHash === "string" ? data.txHash : undefined, payload: valid ? wirePayload : undefined, paymentRequirements: valid ? wireRequirements : undefined, + ...(extensionResponses && { extensionResponses }), }; } catch (error) { if (error instanceof Error && error.name === "AbortError") { @@ -683,9 +914,15 @@ export async function x402Settle( timeoutMs: number, paymentRequirements: JsonObject | PaymentRequirements | undefined, cdpAuth: CdpAuthLike, -): Promise<{ txHash?: string } & JsonObject> { + options: { + resource?: PaymentResourceInfo; + extensions?: JsonObject; + } = {}, +): Promise<{ txHash?: string; extensionResponses?: JsonObject } & JsonObject> { const wirePayload = canonicalizePaymentPayload(payload, { - fallbackResource: resourceInfoFromRequirements(paymentRequirements), + fallbackResource: + options.resource ?? resourceInfoFromRequirements(paymentRequirements), + serverExtensions: options.extensions, }); const wireRequirements = canonicalizeRequirements(paymentRequirements); const body = @@ -710,6 +947,7 @@ export async function x402Settle( } const data = asObject(await res.json().catch(() => ({}))) ?? {}; + const extensionResponses = parseExtensionResponses(res.headers); return { ...data, txHash: @@ -718,9 +956,21 @@ export async function x402Settle( : typeof data.txHash === "string" ? data.txHash : undefined, + ...(extensionResponses && { extensionResponses }), }; } +function parseExtensionResponses(headers: Headers): JsonObject | undefined { + const value = headers.get("extension-responses"); + if (!value) return undefined; + + try { + return asObject(JSON.parse(Buffer.from(value, "base64").toString("utf8"))) ?? undefined; + } catch { + return undefined; + } +} + export function extractRequestBodySchema( requestBody: | { diff --git a/src/types.ts b/src/types.ts index f0225b8..e4effb7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,11 @@ import type { RequestHandler } from "express"; -import type { JsonObject, JsonSchema } from "./internal/x402.js"; +import type { + BazaarBodyType, + BazaarRouteMetadata, + JsonObject, + JsonSchema, +} from "./internal/x402.js"; /** MPP (Tempo USDC) configuration. Passed to `createDual402` as `mpp`. */ export type MppConfig = { @@ -157,6 +162,13 @@ export type DiscoveryRoute = { requestBodyRequired?: boolean; /** JSON Schema for the successful response. Threaded into the 402 challenge as a Bazaar schema hint. */ responseSchema?: JsonSchema; + /** + * Optional Bazaar extension hints. When omitted, dual402 synthesizes + * best-effort examples from simple `parameters` / `requestBodySchema` and + * `responseSchema` shapes. Provide explicit examples for marketplace-critical + * or complex schemas. + */ + bazaar?: BazaarRouteMetadata; }; /** Config for `dualDiscovery`. */ @@ -193,12 +205,19 @@ export type DualChargeHandler = RequestHandler & { _dualOutputSchemasByMethod?: Record; _dualInputSchemasByRoute?: Record; _dualOutputSchemasByRoute?: Record; + _dualCanonicalMethod?: string; + _dualCanonicalMethodsByPath?: Record; + _dualBazaar?: BazaarRouteMetadata; + _dualBazaarByMethod?: Record; + _dualBazaarByRoute?: Record; _dualServiceName?: string; _dualTags?: string[]; _dualTagsByRoute?: Record; _dualIconUrl?: string; }; +export type { BazaarBodyType, BazaarRouteMetadata }; + /** @internal Resolved MPP config after startup validation. */ export type ResolvedMppConfig = Readonly<{ currency: `0x${string}`; diff --git a/tests/dual402.smoke.mjs b/tests/dual402.smoke.mjs index 6ad89bd..e5b93e9 100644 --- a/tests/dual402.smoke.mjs +++ b/tests/dual402.smoke.mjs @@ -164,10 +164,73 @@ function decodeBase64Json(value) { return JSON.parse(Buffer.from(value, "base64").toString("utf-8")); } -function fakeFetchResponse({ ok, status = ok ? 200 : 500, json, text = "" }) { +function assertBazaarExtensionMatchesSchema(extension) { + assert.ok(extension?.info, "Bazaar extension must include info"); + assert.ok(extension?.schema, "Bazaar extension must include schema"); + assert.equal( + extension.schema.$schema, + "https://json-schema.org/draft/2020-12/schema", + ); + const errors = validateAgainstSchema(extension.info, extension.schema); + assert.deepEqual(errors, [], `Bazaar extension did not match emitted schema: ${errors.join("; ")}`); +} + +function validateAgainstSchema(value, schema, path = "$") { + if (!schema || typeof schema !== "object") return []; + const errors = []; + + if ("const" in schema && !Object.is(value, schema.const)) { + errors.push(`${path} must equal ${JSON.stringify(schema.const)}`); + } + if (Array.isArray(schema.enum) && !schema.enum.some((item) => Object.is(item, value))) { + errors.push(`${path} must be one of ${JSON.stringify(schema.enum)}`); + } + if (schema.type && !matchesJsonSchemaType(value, schema.type)) { + errors.push(`${path} must be ${JSON.stringify(schema.type)}`); + } + + const type = Array.isArray(schema.type) ? schema.type[0] : schema.type; + if ((type === "object" || schema.properties) && value && typeof value === "object" && !Array.isArray(value)) { + const properties = schema.properties && typeof schema.properties === "object" ? schema.properties : {}; + for (const key of schema.required ?? []) { + if (!(key in value)) errors.push(`${path}.${key} is required`); + } + for (const [key, childSchema] of Object.entries(properties)) { + if (key in value) { + errors.push(...validateAgainstSchema(value[key], childSchema, `${path}.${key}`)); + } + } + if (schema.additionalProperties === false) { + for (const key of Object.keys(value)) { + if (!(key in properties)) errors.push(`${path}.${key} is not allowed`); + } + } + } + + if (Array.isArray(value) && schema.items) { + value.forEach((item, index) => { + errors.push(...validateAgainstSchema(item, schema.items, `${path}[${index}]`)); + }); + } + + return errors; +} + +function matchesJsonSchemaType(value, type) { + if (Array.isArray(type)) return type.some((entry) => matchesJsonSchemaType(value, entry)); + if (type === "null") return value === null; + if (type === "array") return Array.isArray(value); + if (type === "object") return !!value && typeof value === "object" && !Array.isArray(value); + if (type === "integer") return Number.isInteger(value); + if (type === "number") return typeof value === "number" && Number.isFinite(value); + return typeof value === type; +} + +function fakeFetchResponse({ ok, status = ok ? 200 : 500, json, text = "", headers = {} }) { return { ok, status, + headers: new Headers(headers), async json() { return json; }, @@ -503,6 +566,21 @@ test("dualDiscovery publishes Bazaar-style x402 resource metadata", async () => }, ]); assert.equal(resource.extensions.bazaar.info.input.method, "GET"); + assert.deepEqual(resource.extensions.bazaar.schema.properties.input.required, [ + "type", + "method", + ]); + assert.deepEqual( + resource.extensions.bazaar.schema.properties.input.properties.method.enum, + ["GET"], + ); + assert.deepEqual(resource.extensions.bazaar.info.input.queryParams, { + symbol: "x", + }); + assert.deepEqual(resource.extensions.bazaar.info.output.example, { + price: 0, + }); + assertBazaarExtensionMatchesSchema(resource.extensions.bazaar); assert.ok( resource.extensions.bazaar.schema.properties.input.properties.queryParams.properties.symbol, ); @@ -530,6 +608,94 @@ test("dualDiscovery publishes Bazaar-style x402 resource metadata", async () => assert.equal(paymentRequired.resource.serviceName, "Quote API"); assert.deepEqual(paymentRequired.resource.tags, ["finance", "quotes"]); assert.equal(paymentRequired.resource.iconUrl, "https://api.example/icon.png"); + assertBazaarExtensionMatchesSchema(paymentRequired.extensions.bazaar); +}); + +test("Bazaar metadata includes canonical POST examples and sanitized service fields", async () => { + const app = makeApp(); + const dual = createDual402(VALID_CONFIG); + const route = paidRoute(dual, { + method: "POST", + path: "/lookup", + amount: "0.02", + operationId: "lookupTransit", + summary: "Lookup transit", + requestBodySchema: { + type: "object", + properties: { + lat: { type: "number", minimum: -90 }, + lng: { type: "number", minimum: -180 }, + }, + required: ["lat", "lng"], + additionalProperties: false, + }, + responseSchema: { + type: "object", + properties: { + results: { type: "array", items: { type: "object" } }, + }, + required: ["results"], + }, + bazaar: { + inputExample: { lat: 40.758, lng: -73.9855 }, + outputExample: { results: [] }, + bodyType: "json", + }, + }); + + dualDiscovery(app, dual, { + serviceName: "Transit API", + tags: ["nyc", "NYC", "transit", "mta", "bus", "bike", "bad tag"], + iconUrl: "http://localhost/icon.svg", + routes: [route], + }); + + const res = makeRes(); + await runHandler( + route.handler, + makeReq({ method: "GET", path: "/lookup", originalUrl: "/lookup?probe=1" }), + res, + ); + + const paymentRequired = decodeBase64Json( + headerValue(res.headers, "payment-required"), + ); + const extension = paymentRequired.extensions.bazaar; + + assert.equal(extension.info.input.method, "POST"); + assert.equal(extension.info.input.bodyType, "json"); + assert.deepEqual(extension.info.input.body, { lat: 40.758, lng: -73.9855 }); + assert.equal(extension.info.input.queryParams, undefined); + assert.deepEqual(extension.info.output.example, { results: [] }); + assert.deepEqual(extension.schema.properties.input.required, [ + "type", + "method", + "bodyType", + "body", + ]); + assert.deepEqual(extension.schema.properties.input.properties.method.enum, ["POST"]); + assertBazaarExtensionMatchesSchema(extension); + + assert.equal(paymentRequired.resource.serviceName, "Transit API"); + assert.deepEqual(paymentRequired.resource.tags, [ + "nyc", + "transit", + "mta", + "bus", + "bike", + ]); + assert.equal(paymentRequired.resource.iconUrl, undefined); + + const discoveryRes = makeRes(); + await app.routes.get("/.well-known/x402")( + makeReq({ host: "api.example", protocol: "https" }), + discoveryRes, + ); + const [resource] = discoveryRes.body.resources; + assert.equal(resource.resource, "https://api.example/lookup"); + assert.deepEqual(resource.tags, ["nyc", "transit", "mta", "bus", "bike"]); + assert.equal(resource.iconUrl, undefined); + assertBazaarExtensionMatchesSchema(resource.extensions.bazaar); }); test("dualDiscovery rejects discovery metadata that would be ambiguous", () => { @@ -552,6 +718,10 @@ test("dualDiscovery rejects discovery metadata that would be ambiguous", () => { () => dualDiscovery(app, dual, { routes: [{ ...baseRoute, method: "bad method" }] }), /invalid HTTP method/, ); + assert.throws( + () => dualDiscovery(app, dual, { routes: [{ ...baseRoute, method: "OPTIONS" }] }), + /invalid HTTP method/, + ); assert.throws( () => dualDiscovery(app, dual, { routes: [{ ...baseRoute, operationId: "" }] }), /operationId/, @@ -573,7 +743,7 @@ test("dualDiscovery rejects discovery metadata that would be ambiguous", () => { ); }); -test("verified x402 requests use CDP body shape and attach receipt headers", async () => { +test("verified x402 requests use CDP body shape without implicit Bazaar discovery", async () => { const { privateKey } = crypto.generateKeyPairSync("ed25519"); const apiKeySecret = privateKey .export({ format: "pem", type: "pkcs8" }) @@ -621,7 +791,22 @@ test("verified x402 requests use CDP body shape and attach receipt headers", asy }, signature: "0xdeadbeef", }, - resource: "https://public.example/paid", + resource: { + url: "https://attacker.example/not-paid", + description: "attacker controlled", + mimeType: "text/plain", + serviceName: "Wrong Service", + tags: ["wrong"], + iconUrl: "https://attacker.example/icon.svg", + }, + extensions: { + bazaar: { + info: { + input: { type: "http", method: "GET", queryParams: { fake: "yes" } }, + }, + schema: { type: "object" }, + }, + }, }), ).toString("base64"); @@ -677,11 +862,13 @@ test("verified x402 requests use CDP body shape and attach receipt headers", asy description: "Paid route", mimeType: "application/json", }); + assert.equal(calls[0].body.paymentPayload.extensions, undefined); assert.deepEqual(calls[1].body.paymentPayload.resource, { url: "https://public.example/paid", description: "Paid route", mimeType: "application/json", }); + assert.equal(calls[1].body.paymentPayload.extensions, undefined); assert.equal(calls[0].body.paymentRequirements.resource, undefined); assert.equal(calls[0].body.paymentPayload.accepted.resource, undefined); @@ -745,6 +932,9 @@ test("Express app performs full unpaid challenge then x402 paid retry flow", asy if (!server) return; const facilitatorCalls = []; + const extensionResponses = Buffer.from( + JSON.stringify({ bazaar: { status: "processing" } }), + ).toString("base64"); global.fetch = async (url, init) => { const target = String(url); if (target.startsWith(VALID_CONFIG.x402.facilitatorUrl)) { @@ -754,11 +944,16 @@ test("Express app performs full unpaid challenge then x402 paid retry flow", asy }); if (target.endsWith("/verify")) { - return fakeFetchResponse({ ok: true, json: { isValid: true } }); + return fakeFetchResponse({ + ok: true, + json: { isValid: true }, + headers: { "extension-responses": extensionResponses }, + }); } return fakeFetchResponse({ ok: true, json: { success: true, transaction: `0x${"d".repeat(64)}` }, + headers: { "extension-responses": extensionResponses }, }); } return originalFetch(url, init); @@ -808,12 +1003,30 @@ test("Express app performs full unpaid challenge then x402 paid retry flow", asy iconUrl: "https://example.com/icon.png", }); assert.ok(facilitatorCalls[0].body.paymentPayload.extensions.bazaar); + assertBazaarExtensionMatchesSchema( + facilitatorCalls[0].body.paymentPayload.extensions.bazaar, + ); + assert.deepEqual(facilitatorCalls[1].body.paymentPayload.resource, { + url: `${baseUrl}/protected`, + description: "Protected data", + mimeType: "application/json", + serviceName: "E2E Test API", + tags: ["integration"], + iconUrl: "https://example.com/icon.png", + }); + assert.ok(facilitatorCalls[1].body.paymentPayload.extensions.bazaar); + assertBazaarExtensionMatchesSchema( + facilitatorCalls[1].body.paymentPayload.extensions.bazaar, + ); assert.equal(facilitatorCalls[1].body.paymentRequirements.payTo, VALID_CONFIG.x402.payTo); const receipt = decodeBase64Json(paid.headers.get("payment-response")); assert.equal(receipt.success, true); assert.equal(receipt.network, VALID_CONFIG.x402.network); assert.equal(receipt.txHash, `0x${"d".repeat(64)}`); + assert.deepEqual(receipt.extensionResponses, { + bazaar: { status: "processing" }, + }); } finally { global.fetch = originalFetch; await closeServer(server);