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
2 changes: 1 addition & 1 deletion typescript/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,4 @@
"turbo": "^2.5.0",
"typescript": "^5.7.3"
}
}
}
2 changes: 1 addition & 1 deletion typescript/packages/core/src/client/x402Client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -419,7 +419,7 @@ export class x402Client {
const partialPayload = await schemeNetworkClient.createPaymentPayload(
paymentRequired.x402Version,
requirements,
{ extensions: paymentRequired.extensions },
{ extensions: paymentRequired.extensions, resource: paymentRequired.resource },
);

let paymentPayload: PaymentPayload;
Expand Down
46 changes: 42 additions & 4 deletions typescript/packages/core/src/http/x402HTTPResourceServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
Network,
PaymentRequirements,
} from "../types";
import { deepEqual } from "../utils";
import { x402Version } from "..";

export const SETTLEMENT_OVERRIDES_HEADER = "Settlement-Overrides";
Expand Down Expand Up @@ -76,9 +77,16 @@ export interface HTTPAdapter {
* Get the parsed request body
* Framework adapters should parse JSON/form data appropriately
*
* @returns The parsed request body
* @returns The parsed request body, synchronously or asynchronously
*/
getBody?(): unknown;
getBody?(): unknown | Promise<unknown>;

/**
* Get the exact request-body bytes without consuming the request stream.
*
* @returns Exact request-body bytes, or undefined when unavailable
*/
getRawBody?(): Uint8Array | undefined | Promise<Uint8Array | undefined>;
}

/**
Expand Down Expand Up @@ -280,6 +288,8 @@ export interface HTTPTransportContext {
responseBody?: Buffer;
/** Response headers set by the route handler (used for settlement overrides) */
responseHeaders?: Record<string, string>;
/** Status selected by the protected handler. */
responseStatus?: number;
}

/**
Expand All @@ -290,6 +300,7 @@ export interface HTTPResponseInstructions {
headers: Record<string, string>;
body?: unknown; // e.g. Paywall for web browser requests, but could be any other type
isHtml?: boolean; // e.g. if body is a paywall, then isHtml is true
isRaw?: boolean; // body is already encoded bytes/text and must not be JSON encoded
}

/**
Expand Down Expand Up @@ -573,6 +584,7 @@ export class x402HTTPResourceServer {
!paymentPayload ? "Payment required" : undefined,
extensions,
transportContext,
paymentPayload ?? undefined,
);

// If no payment provided
Expand Down Expand Up @@ -615,6 +627,28 @@ export class x402HTTPResourceServer {
};
}

// `PaymentPayload.resource` is client-carried. When present, bind it to
// the canonical resource computed for this request before any scheme or
// registry validator uses it. Older clients may omit the optional field.
if (
this.ResourceServer.requiresMatchingPayloadResource(matchingRequirements) &&
paymentPayload.resource !== undefined &&
!deepEqual(paymentPayload.resource, resourceInfo)
) {
const errorResponse = await this.ResourceServer.createPaymentRequiredResponse(
requirements,
resourceInfo,
"Payment resource does not match the protected resource",
extensions,
transportContext,
paymentPayload,
);
return {
type: "payment-error",
response: this.createHTTPResponse(errorResponse, false, paywallConfig),
};
}

const extensionResult = this.ResourceServer.validateExtensions(
paymentRequired,
paymentPayload,
Expand Down Expand Up @@ -650,9 +684,11 @@ export class x402HTTPResourceServer {
transportContext,
paymentPayload,
);
const response = this.createHTTPResponse(errorResponse, false, paywallConfig);
if (verifyResult.httpStatus !== undefined) response.status = verifyResult.httpStatus;
return {
type: "payment-error",
response: this.createHTTPResponse(errorResponse, false, paywallConfig),
response,
};
}

Expand Down Expand Up @@ -862,14 +898,16 @@ export class x402HTTPResourceServer {
return {
type: "payment-error",
response: {
status: 200,
status: skipHandlerResponse?.status ?? 200,
headers: {
...skipHandlerResponse?.headers,
"Content-Type": contentType,
...settleResult.headers,
"Cache-Control": withPrivateCacheControl(null),
},
body,
isHtml: contentType.includes("text/html"),
isRaw: skipHandlerResponse?.isRaw,
},
};
}
Expand Down
28 changes: 27 additions & 1 deletion typescript/packages/core/src/server/x402ResourceServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,20 @@ export interface VerifyResultContext extends VerifyContext {
* (e.g. cooperative refund). Travels in-process only — never on the facilitator wire.
*/
export interface SkipHandlerDirective {
/** Original successful handler status, when replaying a stored result. */
status?: number;
contentType?: string;
body?: unknown;
/** Additional original handler headers safe to replay. */
headers?: Record<string, string>;
/** Send `body` as bytes/text instead of JSON encoding it. */
isRaw?: boolean;
}

export type ResourceVerifyRespone = VerifyResponse & {
skipHandler?: SkipHandlerDirective;
/** Optional transport status selected by a local after-verify guard. */
httpStatus?: number;
};

export interface VerifyFailureContext extends VerifyContext {
Expand Down Expand Up @@ -141,7 +149,7 @@ export type AfterVerifyHook = (
) => Promise<
| void
| { skipHandler: true; response?: SkipHandlerDirective }
| { abort: true; reason: string; message?: string }
| { abort: true; reason: string; message?: string; status?: number }
>;

export type OnVerifyFailureHook = (
Expand Down Expand Up @@ -354,6 +362,22 @@ export class x402ResourceServer {
return !!findByNetworkAndScheme(this.registeredServerSchemes, scheme, network);
}

/**
* Whether the matched scheme binds a client-carried resource to this request.
*
* @param requirements - Matched payment requirements.
* @returns True when the registered scheme requires exact resource matching.
*/
requiresMatchingPayloadResource(requirements: PaymentRequirements): boolean {
return (
findByNetworkAndScheme(
this.registeredServerSchemes,
requirements.scheme,
requirements.network as Network,
)?.requireMatchingPayloadResource === true
);
}

/**
* Returns the decimal precision for the asset specified in the given payment requirements.
* Looks up the registered scheme for the network and delegates to its getAssetDecimals
Expand Down Expand Up @@ -849,6 +873,7 @@ export class x402ResourceServer {

const context: SchemePaymentRequiredContext = {
requirements: workingAccepts,
requirement: accept,
paymentPayload,
resourceInfo,
error,
Expand Down Expand Up @@ -1454,6 +1479,7 @@ export class x402ResourceServer {
isValid: false,
invalidReason: directive.reason,
invalidMessage: directive.message,
httpStatus: directive.status,
};
}
if (directive && "skipHandler" in directive && directive.skipHandler) {
Expand Down
10 changes: 8 additions & 2 deletions typescript/packages/core/src/types/mechanisms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,13 @@ export type PaymentPayloadResult = Pick<PaymentPayload, "x402Version" | "payload

/**
* Context passed to scheme's createPaymentPayload for extensions awareness.
* Contains the server-declared extensions from PaymentRequired so the scheme
* can check which extensions are advertised and respond accordingly.
* Contains the protected resource and server-declared extensions from
* PaymentRequired so the scheme can validate resource-bound payment metadata.
*/
export interface PaymentPayloadContext {
extensions?: Record<string, unknown>;
/** Protected resource the payment is being created for. */
resource?: ResourceInfo;
}

export interface SchemeClientHooks {
Expand Down Expand Up @@ -173,6 +175,8 @@ export type SchemeEnrichSettlementResponseHook = (

export interface SchemePaymentRequiredContext {
requirements: PaymentRequirements[];
/** Requirement currently being enriched by the matched scheme implementation. */
requirement: PaymentRequirements;
paymentPayload?: DeepReadonly<PaymentPayload>;
resourceInfo: ResourceInfo;
error?: string;
Expand All @@ -187,6 +191,8 @@ export type SchemeEnrichPaymentRequiredResponseHook = (
export interface SchemeNetworkServer {
readonly scheme: string;
readonly schemeHooks?: SchemeServerHooks;
/** Require an optional client-carried resource to equal the current protected resource. */
readonly requireMatchingPayloadResource?: boolean;
enrichPaymentRequiredResponse?: SchemeEnrichPaymentRequiredResponseHook;
enrichSettlementPayload?: SchemeEnrichSettlementPayloadHook;
enrichSettlementResponse?: SchemeEnrichSettlementResponseHook;
Expand Down
2 changes: 1 addition & 1 deletion typescript/packages/core/test/mocks/cash/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export class CashSchemeNetworkClient implements SchemeNetworkClient {
network: requirements.network,
payload: {
signature: `~${this.payer}`,
validUntil: (Date.now() + requirements.maxTimeoutSeconds).toString(),
validUntil: (Date.now() + requirements.maxTimeoutSeconds * 1000).toString(),
name: this.payer,
},
accepted: requirements,
Expand Down
15 changes: 13 additions & 2 deletions typescript/packages/core/test/mocks/generic/MockSchemeClient.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { SchemeClientHooks, SchemeNetworkClient } from "../../../src/types/mechanisms";
import {
PaymentPayloadContext,
SchemeClientHooks,
SchemeNetworkClient,
} from "../../../src/types/mechanisms";
import { PaymentPayload, PaymentRequirements } from "../../../src/types/payments";

/**
Expand All @@ -13,6 +17,7 @@ export class MockSchemeNetworkClient implements SchemeNetworkClient {
public createPaymentPayloadCalls: Array<{
x402Version: number;
requirements: PaymentRequirements;
context?: PaymentPayloadContext;
}> = [];

/**
Expand All @@ -37,12 +42,18 @@ export class MockSchemeNetworkClient implements SchemeNetworkClient {
*
* @param x402Version
* @param paymentRequirements
* @param context
*/
async createPaymentPayload(
x402Version: number,
paymentRequirements: PaymentRequirements,
context?: PaymentPayloadContext,
): Promise<Pick<PaymentPayload, "x402Version" | "payload">> {
this.createPaymentPayloadCalls.push({ x402Version, requirements: paymentRequirements });
this.createPaymentPayloadCalls.push({
x402Version,
requirements: paymentRequirements,
...(context ? { context } : {}),
});

if (this.payloadResult instanceof Error) {
throw this.payloadResult;
Expand Down
4 changes: 4 additions & 0 deletions typescript/packages/core/test/unit/client/x402Client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,10 @@ describe("x402Client", () => {

expect(mockClient.createPaymentPayloadCalls.length).toBe(1);
expect(mockClient.createPaymentPayloadCalls[0].x402Version).toBe(2);
expect(mockClient.createPaymentPayloadCalls[0].context).toEqual({
extensions: paymentRequired.extensions,
resource: paymentRequired.resource,
});
});
});

Expand Down
Loading
Loading