Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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`,
Expand Down
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
44 changes: 39 additions & 5 deletions src/charge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -41,14 +42,23 @@ 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] ??
handler._dualInputSchemasByMethod?.[method] ?? handler._dualInputSchema;
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({
Expand All @@ -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);
Expand Down Expand Up @@ -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(
Expand All @@ -119,6 +140,8 @@ export function createChargeFactory({
extra: x402Config.extra,
inputSchema,
outputSchema,
bazaarMetadata,
method,
serviceName: handler._dualServiceName,
tags,
iconUrl: handler._dualIconUrl,
Expand All @@ -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();
Expand All @@ -159,6 +187,7 @@ export function createChargeFactory({
extra: x402Config.extra,
inputSchema,
outputSchema,
...bazaarMetadata,
serviceName: handler._dualServiceName,
tags,
iconUrl: handler._dualIconUrl,
Expand Down Expand Up @@ -229,6 +258,7 @@ function applyReceiptHeader(
res: Response,
network: string,
txHash: string | undefined,
extensionResponses?: JsonObject,
): void {
if (txHash && !res.headersSent) {
res.setHeader(
Expand All @@ -238,6 +268,7 @@ function applyReceiptHeader(
success: true,
txHash,
network,
...(extensionResponses && { extensionResponses }),
}),
).toString("base64"),
);
Expand All @@ -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;
Expand All @@ -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"),
Expand Down
53 changes: 47 additions & 6 deletions src/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import {
extractRequestBodySchema,
parametersToSchema,
resolveBaseUrl,
sanitizeResourceServiceMetadata,
toSmallestUnit,
type BazaarRouteMetadata,
type JsonObject,
type JsonSchema,
} from "./internal/x402.js";
Expand All @@ -30,11 +32,9 @@ const OPENAPI_HTTP_METHODS = new Set([
"DELETE",
"GET",
"HEAD",
"OPTIONS",
"PATCH",
"POST",
"PUT",
"TRACE",
]);

/**
Expand Down Expand Up @@ -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 ??= {};
Expand All @@ -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) {
Expand Down Expand Up @@ -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,
Expand All @@ -293,9 +319,7 @@ function buildX402Resource(args: {
}),
],
...(extensions && { extensions }),
...(serviceName && { serviceName }),
...(tags.length > 0 && { tags }),
...(config.iconUrl && { iconUrl: config.iconUrl }),
...serviceMetadata,
};
}

Expand Down Expand Up @@ -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:
| {
Expand Down
2 changes: 2 additions & 0 deletions src/express.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export { maskHex } from "./internal/x402.js";
export { parseCdpPrivateKey } from "./internal/cdp.js";
export type {
CdpAuth,
BazaarBodyType,
BazaarRouteMetadata,
ChargeOptions,
DiscoveryConfig,
DiscoveryRoute,
Expand Down
Loading
Loading