first commit
This commit is contained in:
11
packages/x402/locals.d.ts
vendored
Normal file
11
packages/x402/locals.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { X402Enforcer } from "./src/types.js";
|
||||
|
||||
declare global {
|
||||
namespace App {
|
||||
interface Locals {
|
||||
x402: X402Enforcer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
56
packages/x402/package.json
Normal file
56
packages/x402/package.json
Normal file
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"name": "@emdashcms/x402",
|
||||
"version": "0.0.0",
|
||||
"description": "x402 payment protocol integration for Astro sites",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/cloudflare/emdash.git",
|
||||
"directory": "packages/x402"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"locals.d.ts"
|
||||
],
|
||||
"type": "module",
|
||||
"main": "dist/index.mjs",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.mts",
|
||||
"default": "./dist/index.mjs"
|
||||
},
|
||||
"./middleware": {
|
||||
"types": "./dist/middleware.d.mts",
|
||||
"default": "./dist/middleware.mjs"
|
||||
},
|
||||
"./locals": {
|
||||
"types": "./locals.d.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsdown",
|
||||
"dev": "tsdown --watch",
|
||||
"check": "publint && attw --pack --ignore-rules=cjs-resolves-to-esm --ignore-rules=no-resolution",
|
||||
"test": "vitest",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@x402/core": "^2.8.0",
|
||||
"@x402/evm": "^2.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@arethetypeswrong/cli": "catalog:",
|
||||
"astro": "catalog:",
|
||||
"publint": "catalog:",
|
||||
"tsdown": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"astro": ">=6.0.0-beta.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@x402/svm": "^2.8.0"
|
||||
}
|
||||
}
|
||||
231
packages/x402/src/enforcer.ts
Normal file
231
packages/x402/src/enforcer.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* x402 Payment Enforcer
|
||||
*
|
||||
* Creates the x402 enforcement interface. Uses the @x402/core SDK
|
||||
* to handle the payment protocol negotiation.
|
||||
*/
|
||||
|
||||
import {
|
||||
decodePaymentSignatureHeader,
|
||||
encodePaymentRequiredHeader,
|
||||
encodePaymentResponseHeader,
|
||||
} from "@x402/core/http";
|
||||
import { HTTPFacilitatorClient, x402ResourceServer, type ResourceConfig } from "@x402/core/server";
|
||||
|
||||
import type { EnforceOptions, EnforceResult, X402Config, X402Enforcer } from "./types.js";
|
||||
|
||||
const PAYMENT_SIGNATURE_HEADER = "payment-signature";
|
||||
const PAYMENT_REQUIRED_HEADER = "PAYMENT-REQUIRED";
|
||||
const PAYMENT_RESPONSE_HEADER = "PAYMENT-RESPONSE";
|
||||
|
||||
const DEFAULT_FACILITATOR_URL = "https://x402.org/facilitator";
|
||||
const DEFAULT_SCHEME = "exact";
|
||||
const DEFAULT_MAX_TIMEOUT_SECONDS = 60;
|
||||
const DEFAULT_BOT_SCORE_THRESHOLD = 30;
|
||||
|
||||
/**
|
||||
* Cached resource server instance.
|
||||
* Initialized once per process, reused across requests.
|
||||
*/
|
||||
let _resourceServer: x402ResourceServer | null = null;
|
||||
let _initPromise: Promise<void> | null = null;
|
||||
|
||||
/**
|
||||
* Get or create the x402ResourceServer singleton.
|
||||
*/
|
||||
async function getResourceServer(config: X402Config): Promise<x402ResourceServer> {
|
||||
if (!_resourceServer) {
|
||||
const facilitatorUrl = config.facilitatorUrl ?? DEFAULT_FACILITATOR_URL;
|
||||
const facilitator = new HTTPFacilitatorClient({ url: facilitatorUrl });
|
||||
const server = new x402ResourceServer(facilitator);
|
||||
|
||||
// Register EVM scheme (default)
|
||||
if (config.evm !== false) {
|
||||
try {
|
||||
const evmMod = await import("@x402/evm/exact/server");
|
||||
const evmScheme = new evmMod.ExactEvmScheme();
|
||||
server.register("eip155:*" as `${string}:${string}`, evmScheme);
|
||||
} catch {
|
||||
// @x402/evm not installed -- skip EVM support
|
||||
}
|
||||
}
|
||||
|
||||
// Register SVM scheme (opt-in)
|
||||
if (config.svm) {
|
||||
try {
|
||||
const svmMod = await import("@x402/svm/exact/server");
|
||||
const svmScheme = new svmMod.ExactSvmScheme();
|
||||
server.register("solana:*" as `${string}:${string}`, svmScheme);
|
||||
} catch {
|
||||
// @x402/svm not installed -- skip Solana support
|
||||
}
|
||||
}
|
||||
|
||||
_resourceServer = server;
|
||||
_initPromise = server.initialize();
|
||||
}
|
||||
|
||||
if (_initPromise) {
|
||||
await _initPromise;
|
||||
_initPromise = null;
|
||||
}
|
||||
|
||||
return _resourceServer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a request is from a bot using Cloudflare Bot Management.
|
||||
* Returns true if the request is likely from a bot, false otherwise.
|
||||
* When bot management data is unavailable (local dev, non-CF deployment),
|
||||
* returns false (treat as human).
|
||||
*/
|
||||
function isBot(request: Request, threshold: number): boolean {
|
||||
// Cloudflare Workers expose cf properties on the request
|
||||
const cf = (request as unknown as { cf?: { botManagement?: { score?: number } } }).cf;
|
||||
const score = cf?.botManagement?.score;
|
||||
if (score == null) return false;
|
||||
return score < threshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an X402Enforcer for the given configuration.
|
||||
* Called once by the middleware, reused across requests.
|
||||
*/
|
||||
export function createEnforcer(config: X402Config): X402Enforcer {
|
||||
const botScoreThreshold = config.botScoreThreshold ?? DEFAULT_BOT_SCORE_THRESHOLD;
|
||||
|
||||
return {
|
||||
async enforce(request: Request, options?: EnforceOptions): Promise<Response | EnforceResult> {
|
||||
// In botOnly mode, skip enforcement for humans
|
||||
if (config.botOnly && !isBot(request, botScoreThreshold)) {
|
||||
return { paid: false, skipped: true, responseHeaders: {} };
|
||||
}
|
||||
|
||||
const server = await getResourceServer(config);
|
||||
|
||||
const price = options?.price ?? config.defaultPrice;
|
||||
if (price == null) {
|
||||
throw new Error(
|
||||
"x402: No price specified. Pass a price in enforce() options or set defaultPrice in the config.",
|
||||
);
|
||||
}
|
||||
|
||||
const payTo = options?.payTo ?? config.payTo;
|
||||
const network = options?.network ?? config.network;
|
||||
const scheme = options?.scheme ?? config.scheme ?? DEFAULT_SCHEME;
|
||||
const maxTimeoutSeconds = config.maxTimeoutSeconds ?? DEFAULT_MAX_TIMEOUT_SECONDS;
|
||||
|
||||
const resourceConfig: ResourceConfig = {
|
||||
scheme,
|
||||
payTo,
|
||||
price: normalizePrice(price),
|
||||
network,
|
||||
maxTimeoutSeconds,
|
||||
};
|
||||
|
||||
const url = new URL(request.url);
|
||||
const resourceInfo = {
|
||||
url: url.pathname,
|
||||
description: options?.description,
|
||||
mimeType: options?.mimeType,
|
||||
};
|
||||
|
||||
// Check for payment signature header
|
||||
const paymentHeader =
|
||||
request.headers.get(PAYMENT_SIGNATURE_HEADER) || request.headers.get("PAYMENT-SIGNATURE");
|
||||
|
||||
if (!paymentHeader) {
|
||||
return make402(server, resourceConfig, resourceInfo, "Payment required");
|
||||
}
|
||||
|
||||
// Payment present -- decode and verify
|
||||
const paymentPayload = decodePaymentSignatureHeader(paymentHeader);
|
||||
const requirements = await server.buildPaymentRequirements(resourceConfig);
|
||||
const matchingReqs = server.findMatchingRequirements(requirements, paymentPayload);
|
||||
|
||||
if (!matchingReqs) {
|
||||
return make402(
|
||||
server,
|
||||
resourceConfig,
|
||||
resourceInfo,
|
||||
"Payment does not match accepted requirements",
|
||||
);
|
||||
}
|
||||
|
||||
// Verify with facilitator
|
||||
const verifyResult = await server.verifyPayment(paymentPayload, matchingReqs);
|
||||
|
||||
if (!verifyResult.isValid) {
|
||||
return make402(
|
||||
server,
|
||||
resourceConfig,
|
||||
resourceInfo,
|
||||
verifyResult.invalidReason ?? "Payment verification failed",
|
||||
);
|
||||
}
|
||||
|
||||
// Settle
|
||||
const settleResult = await server.settlePayment(paymentPayload, matchingReqs);
|
||||
|
||||
const responseHeaders: Record<string, string> = {};
|
||||
if (settleResult) {
|
||||
responseHeaders[PAYMENT_RESPONSE_HEADER] = encodePaymentResponseHeader(settleResult);
|
||||
}
|
||||
|
||||
return {
|
||||
paid: true,
|
||||
skipped: false,
|
||||
payer: verifyResult.payer,
|
||||
settlement: settleResult,
|
||||
responseHeaders,
|
||||
};
|
||||
},
|
||||
|
||||
applyHeaders(result: EnforceResult, response: { headers: Headers }): void {
|
||||
for (const [key, value] of Object.entries(result.responseHeaders)) {
|
||||
response.headers.set(key, value);
|
||||
}
|
||||
},
|
||||
|
||||
hasPayment(request: Request): boolean {
|
||||
return !!(
|
||||
request.headers.get(PAYMENT_SIGNATURE_HEADER) || request.headers.get("PAYMENT-SIGNATURE")
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Build and return a 402 Response */
|
||||
async function make402(
|
||||
server: x402ResourceServer,
|
||||
resourceConfig: ResourceConfig,
|
||||
resourceInfo: { url: string; description?: string; mimeType?: string },
|
||||
error: string,
|
||||
): Promise<Response> {
|
||||
const requirements = await server.buildPaymentRequirements(resourceConfig);
|
||||
const paymentRequired = await server.createPaymentRequiredResponse(
|
||||
requirements,
|
||||
resourceInfo,
|
||||
error,
|
||||
);
|
||||
|
||||
return new Response(JSON.stringify(paymentRequired), {
|
||||
status: 402,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
[PAYMENT_REQUIRED_HEADER]: encodePaymentRequiredHeader(paymentRequired),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a user-friendly price into the format expected by x402 SDK.
|
||||
*/
|
||||
function normalizePrice(
|
||||
price: string | number | { amount: string; asset: string; extra?: Record<string, unknown> },
|
||||
): string | number | { amount: string; asset: string; extra?: Record<string, unknown> } {
|
||||
if (typeof price === "string" && price.startsWith("$")) {
|
||||
return price.slice(1);
|
||||
}
|
||||
return price;
|
||||
}
|
||||
89
packages/x402/src/index.ts
Normal file
89
packages/x402/src/index.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* @emdashcms/x402 -- x402 Payment Integration for Astro
|
||||
*
|
||||
* An Astro integration that provides x402 payment enforcement via
|
||||
* Astro.locals.x402. Supports bot-only mode using Cloudflare Bot Management.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // astro.config.mjs
|
||||
* import { x402 } from "@emdashcms/x402";
|
||||
*
|
||||
* export default defineConfig({
|
||||
* integrations: [
|
||||
* x402({
|
||||
* payTo: "0xYourWallet",
|
||||
* network: "eip155:8453",
|
||||
* defaultPrice: "$0.01",
|
||||
* botOnly: true,
|
||||
* }),
|
||||
* ],
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* ```astro
|
||||
* ---
|
||||
* const { x402 } = Astro.locals;
|
||||
*
|
||||
* const result = await x402.enforce(Astro.request, { price: "$0.05" });
|
||||
* if (result instanceof Response) return result;
|
||||
*
|
||||
* x402.applyHeaders(result, Astro.response);
|
||||
* ---
|
||||
* <article>Premium content here</article>
|
||||
* ```
|
||||
*/
|
||||
|
||||
import type { AstroIntegration } from "astro";
|
||||
|
||||
import type { X402Config } from "./types.js";
|
||||
|
||||
const VIRTUAL_MODULE_ID = "virtual:x402/config";
|
||||
const RESOLVED_VIRTUAL_MODULE_ID = "\0" + VIRTUAL_MODULE_ID;
|
||||
|
||||
/**
|
||||
* Create the x402 Astro integration.
|
||||
*/
|
||||
export function x402(config: X402Config): AstroIntegration {
|
||||
return {
|
||||
name: "@emdashcms/x402",
|
||||
hooks: {
|
||||
"astro:config:setup": ({ addMiddleware, updateConfig }) => {
|
||||
// Inject the virtual module that provides config to the middleware
|
||||
updateConfig({
|
||||
vite: {
|
||||
plugins: [
|
||||
{
|
||||
name: "x402-virtual-config",
|
||||
resolveId(id: string) {
|
||||
if (id === VIRTUAL_MODULE_ID) return RESOLVED_VIRTUAL_MODULE_ID;
|
||||
},
|
||||
load(id: string) {
|
||||
if (id === RESOLVED_VIRTUAL_MODULE_ID) {
|
||||
return `export default ${JSON.stringify(config)}`;
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// Register the middleware that puts the enforcer on locals
|
||||
addMiddleware({
|
||||
entrypoint: "@emdashcms/x402/middleware",
|
||||
order: "pre",
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Re-export types for convenience
|
||||
export type {
|
||||
EnforceOptions,
|
||||
EnforceResult,
|
||||
Network,
|
||||
Price,
|
||||
X402Config,
|
||||
X402Enforcer,
|
||||
} from "./types.js";
|
||||
24
packages/x402/src/middleware.ts
Normal file
24
packages/x402/src/middleware.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* x402 Astro Middleware
|
||||
*
|
||||
* Injected by the x402 integration. Creates the enforcer and
|
||||
* places it on Astro.locals.x402 for use in page frontmatter.
|
||||
*
|
||||
* The config is passed via the virtual module resolved by the integration.
|
||||
*/
|
||||
|
||||
import { defineMiddleware } from "astro:middleware";
|
||||
// The integration injects config via a virtual module.
|
||||
// @ts-ignore -- virtual module, resolved at build time
|
||||
import x402Config from "virtual:x402/config";
|
||||
|
||||
import { createEnforcer } from "./enforcer.js";
|
||||
import type { X402Config } from "./types.js";
|
||||
|
||||
const config = x402Config as X402Config;
|
||||
const enforcer = createEnforcer(config);
|
||||
|
||||
export const onRequest = defineMiddleware(async (context, next) => {
|
||||
context.locals.x402 = enforcer;
|
||||
return next();
|
||||
});
|
||||
141
packages/x402/src/types.ts
Normal file
141
packages/x402/src/types.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* x402 Payment Integration Types
|
||||
*/
|
||||
|
||||
import type { SettleResponse } from "@x402/core/types";
|
||||
|
||||
/** CAIP-2 network identifier (e.g., "eip155:8453" for Base mainnet) */
|
||||
export type Network = `${string}:${string}`;
|
||||
|
||||
/** Human-readable price: "$0.10", "0.50", or atomic units { amount, asset } */
|
||||
export type Price =
|
||||
| string
|
||||
| number
|
||||
| { amount: string; asset: string; extra?: Record<string, unknown> };
|
||||
|
||||
/**
|
||||
* Configuration for the x402 Astro integration.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { x402 } from "@emdashcms/x402";
|
||||
*
|
||||
* export default defineConfig({
|
||||
* integrations: [
|
||||
* x402({
|
||||
* payTo: "0xYourWallet",
|
||||
* network: "eip155:8453",
|
||||
* defaultPrice: "$0.01",
|
||||
* botOnly: true,
|
||||
* }),
|
||||
* ],
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export interface X402Config {
|
||||
/** Destination wallet address for payments */
|
||||
payTo: string;
|
||||
/** CAIP-2 network identifier */
|
||||
network: Network;
|
||||
/** Default price for content (can be overridden per-page) */
|
||||
defaultPrice?: Price;
|
||||
/** Facilitator URL (defaults to x402.org testnet facilitator) */
|
||||
facilitatorUrl?: string;
|
||||
/** Payment scheme (defaults to "exact") */
|
||||
scheme?: string;
|
||||
/** Maximum timeout for payment signatures in seconds (defaults to 60) */
|
||||
maxTimeoutSeconds?: number;
|
||||
/** Enable EVM chain support (defaults to true) */
|
||||
evm?: boolean;
|
||||
/** Enable Solana chain support (defaults to false) */
|
||||
svm?: boolean;
|
||||
/**
|
||||
* Only enforce payment for bots/agents, not humans.
|
||||
* Uses Cloudflare Bot Management score from request.cf.botManagement.score.
|
||||
* Requires Cloudflare deployment with Bot Management enabled.
|
||||
* When true, requests with a bot score >= botScoreThreshold are treated as
|
||||
* human and enforcement is skipped.
|
||||
*/
|
||||
botOnly?: boolean;
|
||||
/**
|
||||
* Bot score threshold. Requests with a score below this are treated as bots.
|
||||
* Only used when botOnly is true. Defaults to 30.
|
||||
* Score range: 1 (almost certainly bot) to 99 (almost certainly human).
|
||||
*/
|
||||
botScoreThreshold?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options passed to enforce() to override defaults for a specific page.
|
||||
*/
|
||||
export interface EnforceOptions {
|
||||
/** Override the price for this specific request */
|
||||
price?: Price;
|
||||
/** Override the destination wallet */
|
||||
payTo?: string;
|
||||
/** Override the network */
|
||||
network?: Network;
|
||||
/** Override the payment scheme */
|
||||
scheme?: string;
|
||||
/** Resource description for the payment prompt */
|
||||
description?: string;
|
||||
/** MIME type hint for the resource */
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a successful payment enforcement check.
|
||||
* Returned when the request should proceed (either paid or skipped).
|
||||
*/
|
||||
export interface EnforceResult {
|
||||
/** Whether payment was required and verified */
|
||||
paid: boolean;
|
||||
/** Whether enforcement was skipped (e.g., human in botOnly mode) */
|
||||
skipped: boolean;
|
||||
/** The payer's wallet address (if paid) */
|
||||
payer?: string;
|
||||
/** Settlement response (if payment was settled) */
|
||||
settlement?: SettleResponse;
|
||||
/** Headers to add to the response (e.g., PAYMENT-RESPONSE) */
|
||||
responseHeaders: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The x402 enforcement interface available on Astro.locals.x402.
|
||||
*/
|
||||
export interface X402Enforcer {
|
||||
/**
|
||||
* Check if the current request includes valid payment.
|
||||
* If not paid, returns a 402 Response that should be returned directly.
|
||||
* If paid (or skipped in botOnly mode), returns an EnforceResult.
|
||||
*
|
||||
* @param request - The incoming Request object
|
||||
* @param options - Optional overrides for this specific enforcement
|
||||
* @returns A 402 Response (return it) or an EnforceResult (proceed with page render)
|
||||
*
|
||||
* @example
|
||||
* ```astro
|
||||
* ---
|
||||
* const { x402 } = Astro.locals;
|
||||
*
|
||||
* const result = await x402.enforce(Astro.request, { price: "$0.01" });
|
||||
* if (result instanceof Response) return result;
|
||||
*
|
||||
* x402.applyHeaders(result, Astro.response);
|
||||
* ---
|
||||
* ```
|
||||
*/
|
||||
enforce(request: Request, options?: EnforceOptions): Promise<Response | EnforceResult>;
|
||||
|
||||
/**
|
||||
* Apply x402 response headers (e.g., PAYMENT-RESPONSE) to the Astro response.
|
||||
* Call this after a successful enforce() to include settlement proof in the response.
|
||||
*/
|
||||
applyHeaders(result: EnforceResult, response: { headers: Headers }): void;
|
||||
|
||||
/**
|
||||
* Check if a request has a payment signature without verifying it.
|
||||
* Useful for conditional rendering without enforcement.
|
||||
*/
|
||||
hasPayment(request: Request): boolean;
|
||||
}
|
||||
416
packages/x402/tests/enforcer.test.ts
Normal file
416
packages/x402/tests/enforcer.test.ts
Normal file
@@ -0,0 +1,416 @@
|
||||
/**
|
||||
* x402 Enforcer Tests
|
||||
*
|
||||
* Tests the x402 payment enforcement logic:
|
||||
* - createEnforcer() creates a valid enforcer
|
||||
* - enforce() returns 402 when no payment header is present
|
||||
* - enforce() verifies and settles valid payments
|
||||
* - enforce() returns 402 for invalid payments
|
||||
* - hasPayment() checks for payment headers
|
||||
* - applyHeaders() sets response headers
|
||||
* - botOnly mode skips enforcement for humans
|
||||
* - Price normalization ($ prefix stripping)
|
||||
* - Error when no price is configured
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import type { X402Config, X402Enforcer } from "../src/types.js";
|
||||
|
||||
// Mock instances
|
||||
const mockBuildPaymentRequirements = vi.fn();
|
||||
const mockCreatePaymentRequiredResponse = vi.fn();
|
||||
const mockFindMatchingRequirements = vi.fn();
|
||||
const mockVerifyPayment = vi.fn();
|
||||
const mockSettlePayment = vi.fn();
|
||||
const mockInitialize = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const mockResourceServer = {
|
||||
buildPaymentRequirements: mockBuildPaymentRequirements,
|
||||
createPaymentRequiredResponse: mockCreatePaymentRequiredResponse,
|
||||
findMatchingRequirements: mockFindMatchingRequirements,
|
||||
verifyPayment: mockVerifyPayment,
|
||||
settlePayment: mockSettlePayment,
|
||||
initialize: mockInitialize,
|
||||
register: vi.fn().mockReturnThis(),
|
||||
};
|
||||
|
||||
const mockEncodePaymentRequiredHeader = vi.fn().mockReturnValue("encoded-payment-required");
|
||||
const mockDecodePaymentSignatureHeader = vi.fn();
|
||||
const mockEncodePaymentResponseHeader = vi.fn().mockReturnValue("encoded-payment-response");
|
||||
|
||||
vi.mock("@x402/core/server", () => ({
|
||||
HTTPFacilitatorClient: vi.fn(),
|
||||
x402ResourceServer: vi.fn().mockImplementation(function () {
|
||||
return mockResourceServer;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@x402/core/http", () => ({
|
||||
encodePaymentRequiredHeader: (...args: unknown[]) => mockEncodePaymentRequiredHeader(...args),
|
||||
decodePaymentSignatureHeader: (...args: unknown[]) => mockDecodePaymentSignatureHeader(...args),
|
||||
encodePaymentResponseHeader: (...args: unknown[]) => mockEncodePaymentResponseHeader(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@x402/evm/exact/server", () => ({
|
||||
ExactEvmScheme: vi.fn(),
|
||||
}));
|
||||
|
||||
const defaultConfig: X402Config = {
|
||||
payTo: "0xTestWallet",
|
||||
network: "eip155:8453",
|
||||
defaultPrice: "$0.01",
|
||||
facilitatorUrl: "https://test-facilitator.example.com",
|
||||
};
|
||||
|
||||
function makeRequest(
|
||||
url: string,
|
||||
headers?: Record<string, string>,
|
||||
cf?: { botManagement?: { score?: number } },
|
||||
): Request {
|
||||
const req = new Request(url, { headers });
|
||||
if (cf) {
|
||||
(req as unknown as { cf: typeof cf }).cf = cf;
|
||||
}
|
||||
return req;
|
||||
}
|
||||
|
||||
/** Re-import the enforcer module to reset the cached singleton */
|
||||
async function freshEnforcer(config: X402Config): Promise<X402Enforcer> {
|
||||
const mod = await import("../src/enforcer.js");
|
||||
return mod.createEnforcer(config);
|
||||
}
|
||||
|
||||
describe("createEnforcer()", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
mockBuildPaymentRequirements.mockReset();
|
||||
mockCreatePaymentRequiredResponse.mockReset();
|
||||
mockFindMatchingRequirements.mockReset();
|
||||
mockVerifyPayment.mockReset();
|
||||
mockSettlePayment.mockReset();
|
||||
mockInitialize.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("enforce()", () => {
|
||||
it("returns 402 when no payment header is present", async () => {
|
||||
const enforcer = await freshEnforcer(defaultConfig);
|
||||
const request = makeRequest("https://example.com/premium-article");
|
||||
|
||||
const mockRequirements = [{ scheme: "exact", network: "eip155:8453" }];
|
||||
const mockPaymentRequired = {
|
||||
x402Version: 2,
|
||||
accepts: mockRequirements,
|
||||
resource: { url: "/premium-article" },
|
||||
};
|
||||
|
||||
mockBuildPaymentRequirements.mockResolvedValue(mockRequirements);
|
||||
mockCreatePaymentRequiredResponse.mockResolvedValue(mockPaymentRequired);
|
||||
|
||||
const result = await enforcer.enforce(request);
|
||||
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
const response = result as Response;
|
||||
expect(response.status).toBe(402);
|
||||
expect(response.headers.get("PAYMENT-REQUIRED")).toBe("encoded-payment-required");
|
||||
expect(response.headers.get("Content-Type")).toBe("application/json");
|
||||
|
||||
const body = await response.json();
|
||||
expect(body.x402Version).toBe(2);
|
||||
});
|
||||
|
||||
it("returns EnforceResult when payment is valid", async () => {
|
||||
const enforcer = await freshEnforcer(defaultConfig);
|
||||
const request = makeRequest("https://example.com/premium-article", {
|
||||
"payment-signature": "valid-payment-sig",
|
||||
});
|
||||
|
||||
const mockPayload = { x402Version: 2, payload: {} };
|
||||
const mockRequirements = [{ scheme: "exact", network: "eip155:8453" }];
|
||||
const mockMatchingReqs = { scheme: "exact", network: "eip155:8453" };
|
||||
|
||||
mockDecodePaymentSignatureHeader.mockReturnValue(mockPayload);
|
||||
mockBuildPaymentRequirements.mockResolvedValue(mockRequirements);
|
||||
mockFindMatchingRequirements.mockReturnValue(mockMatchingReqs);
|
||||
mockVerifyPayment.mockResolvedValue({
|
||||
isValid: true,
|
||||
payer: "0xPayerWallet",
|
||||
});
|
||||
mockSettlePayment.mockResolvedValue({
|
||||
success: true,
|
||||
transaction: "0xTxHash",
|
||||
network: "eip155:8453",
|
||||
payer: "0xPayerWallet",
|
||||
});
|
||||
|
||||
const result = await enforcer.enforce(request);
|
||||
|
||||
expect(result).not.toBeInstanceOf(Response);
|
||||
const enforceResult = result as {
|
||||
paid: boolean;
|
||||
skipped: boolean;
|
||||
payer?: string;
|
||||
responseHeaders: Record<string, string>;
|
||||
};
|
||||
expect(enforceResult.paid).toBe(true);
|
||||
expect(enforceResult.skipped).toBe(false);
|
||||
expect(enforceResult.payer).toBe("0xPayerWallet");
|
||||
expect(enforceResult.responseHeaders["PAYMENT-RESPONSE"]).toBe("encoded-payment-response");
|
||||
});
|
||||
|
||||
it("returns 402 when payment verification fails", async () => {
|
||||
const enforcer = await freshEnforcer(defaultConfig);
|
||||
const request = makeRequest("https://example.com/premium-article", {
|
||||
"payment-signature": "invalid-payment-sig",
|
||||
});
|
||||
|
||||
const mockPayload = { x402Version: 2, payload: {} };
|
||||
const mockRequirements = [{ scheme: "exact", network: "eip155:8453" }];
|
||||
const mockMatchingReqs = { scheme: "exact", network: "eip155:8453" };
|
||||
const mockPaymentRequired = {
|
||||
x402Version: 2,
|
||||
error: "insufficient_balance",
|
||||
accepts: mockRequirements,
|
||||
};
|
||||
|
||||
mockDecodePaymentSignatureHeader.mockReturnValue(mockPayload);
|
||||
mockBuildPaymentRequirements.mockResolvedValue(mockRequirements);
|
||||
mockFindMatchingRequirements.mockReturnValue(mockMatchingReqs);
|
||||
mockVerifyPayment.mockResolvedValue({
|
||||
isValid: false,
|
||||
invalidReason: "insufficient_balance",
|
||||
});
|
||||
mockCreatePaymentRequiredResponse.mockResolvedValue(mockPaymentRequired);
|
||||
|
||||
const result = await enforcer.enforce(request);
|
||||
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect((result as Response).status).toBe(402);
|
||||
});
|
||||
|
||||
it("returns 402 when payment doesn't match requirements", async () => {
|
||||
const enforcer = await freshEnforcer(defaultConfig);
|
||||
const request = makeRequest("https://example.com/premium-article", {
|
||||
"payment-signature": "mismatched-payment-sig",
|
||||
});
|
||||
|
||||
const mockPayload = { x402Version: 2, payload: {} };
|
||||
const mockRequirements = [{ scheme: "exact", network: "eip155:8453" }];
|
||||
const mockPaymentRequired = {
|
||||
x402Version: 2,
|
||||
error: "Payment does not match accepted requirements",
|
||||
accepts: mockRequirements,
|
||||
};
|
||||
|
||||
mockDecodePaymentSignatureHeader.mockReturnValue(mockPayload);
|
||||
mockBuildPaymentRequirements.mockResolvedValue(mockRequirements);
|
||||
mockFindMatchingRequirements.mockReturnValue(undefined);
|
||||
mockCreatePaymentRequiredResponse.mockResolvedValue(mockPaymentRequired);
|
||||
|
||||
const result = await enforcer.enforce(request);
|
||||
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect((result as Response).status).toBe(402);
|
||||
});
|
||||
|
||||
it("throws when no price is configured", async () => {
|
||||
const enforcer = await freshEnforcer({
|
||||
payTo: "0xTestWallet",
|
||||
network: "eip155:8453",
|
||||
});
|
||||
const request = makeRequest("https://example.com/premium-article");
|
||||
|
||||
await expect(enforcer.enforce(request)).rejects.toThrow("No price specified");
|
||||
});
|
||||
|
||||
it("allows overriding price per-request", async () => {
|
||||
const enforcer = await freshEnforcer(defaultConfig);
|
||||
const request = makeRequest("https://example.com/premium-article");
|
||||
|
||||
const mockRequirements = [{ scheme: "exact", network: "eip155:8453" }];
|
||||
mockBuildPaymentRequirements.mockResolvedValue(mockRequirements);
|
||||
mockCreatePaymentRequiredResponse.mockResolvedValue({
|
||||
x402Version: 2,
|
||||
accepts: mockRequirements,
|
||||
});
|
||||
|
||||
await enforcer.enforce(request, { price: "$0.50" });
|
||||
|
||||
expect(mockBuildPaymentRequirements).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ price: "0.50" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("allows overriding payTo per-request", async () => {
|
||||
const enforcer = await freshEnforcer(defaultConfig);
|
||||
const request = makeRequest("https://example.com/premium-article");
|
||||
|
||||
const mockRequirements = [{ scheme: "exact", network: "eip155:8453" }];
|
||||
mockBuildPaymentRequirements.mockResolvedValue(mockRequirements);
|
||||
mockCreatePaymentRequiredResponse.mockResolvedValue({
|
||||
x402Version: 2,
|
||||
accepts: mockRequirements,
|
||||
});
|
||||
|
||||
await enforcer.enforce(request, { payTo: "0xOverrideWallet" });
|
||||
|
||||
expect(mockBuildPaymentRequirements).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ payTo: "0xOverrideWallet" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("reads PAYMENT-SIGNATURE header (case-insensitive)", async () => {
|
||||
const enforcer = await freshEnforcer(defaultConfig);
|
||||
const request = makeRequest("https://example.com/premium-article", {
|
||||
"PAYMENT-SIGNATURE": "valid-payment-sig",
|
||||
});
|
||||
|
||||
const mockPayload = { x402Version: 2, payload: {} };
|
||||
const mockRequirements = [{ scheme: "exact", network: "eip155:8453" }];
|
||||
const mockMatchingReqs = { scheme: "exact", network: "eip155:8453" };
|
||||
|
||||
mockDecodePaymentSignatureHeader.mockReturnValue(mockPayload);
|
||||
mockBuildPaymentRequirements.mockResolvedValue(mockRequirements);
|
||||
mockFindMatchingRequirements.mockReturnValue(mockMatchingReqs);
|
||||
mockVerifyPayment.mockResolvedValue({ isValid: true, payer: "0xPayer" });
|
||||
mockSettlePayment.mockResolvedValue({
|
||||
success: true,
|
||||
transaction: "0xTx",
|
||||
network: "eip155:8453",
|
||||
});
|
||||
|
||||
const result = await enforcer.enforce(request);
|
||||
expect(result).not.toBeInstanceOf(Response);
|
||||
});
|
||||
});
|
||||
|
||||
describe("botOnly mode", () => {
|
||||
it("skips enforcement for humans (high bot score)", async () => {
|
||||
const enforcer = await freshEnforcer({ ...defaultConfig, botOnly: true });
|
||||
const request = makeRequest(
|
||||
"https://example.com/article",
|
||||
{},
|
||||
{ botManagement: { score: 90 } },
|
||||
);
|
||||
|
||||
const result = await enforcer.enforce(request);
|
||||
|
||||
expect(result).not.toBeInstanceOf(Response);
|
||||
const enforceResult = result as { paid: boolean; skipped: boolean };
|
||||
expect(enforceResult.paid).toBe(false);
|
||||
expect(enforceResult.skipped).toBe(true);
|
||||
});
|
||||
|
||||
it("enforces for bots (low bot score)", async () => {
|
||||
const enforcer = await freshEnforcer({ ...defaultConfig, botOnly: true });
|
||||
const request = makeRequest(
|
||||
"https://example.com/article",
|
||||
{},
|
||||
{ botManagement: { score: 5 } },
|
||||
);
|
||||
|
||||
const mockRequirements = [{ scheme: "exact", network: "eip155:8453" }];
|
||||
mockBuildPaymentRequirements.mockResolvedValue(mockRequirements);
|
||||
mockCreatePaymentRequiredResponse.mockResolvedValue({
|
||||
x402Version: 2,
|
||||
accepts: mockRequirements,
|
||||
});
|
||||
|
||||
const result = await enforcer.enforce(request);
|
||||
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect((result as Response).status).toBe(402);
|
||||
});
|
||||
|
||||
it("treats missing cf data as human (skips enforcement)", async () => {
|
||||
const enforcer = await freshEnforcer({ ...defaultConfig, botOnly: true });
|
||||
const request = makeRequest("https://example.com/article");
|
||||
|
||||
const result = await enforcer.enforce(request);
|
||||
|
||||
expect(result).not.toBeInstanceOf(Response);
|
||||
const enforceResult = result as { skipped: boolean };
|
||||
expect(enforceResult.skipped).toBe(true);
|
||||
});
|
||||
|
||||
it("respects custom botScoreThreshold", async () => {
|
||||
const enforcer = await freshEnforcer({
|
||||
...defaultConfig,
|
||||
botOnly: true,
|
||||
botScoreThreshold: 50,
|
||||
});
|
||||
// Score 40 < threshold 50 -> bot -> enforce
|
||||
const request = makeRequest(
|
||||
"https://example.com/article",
|
||||
{},
|
||||
{ botManagement: { score: 40 } },
|
||||
);
|
||||
|
||||
const mockRequirements = [{ scheme: "exact", network: "eip155:8453" }];
|
||||
mockBuildPaymentRequirements.mockResolvedValue(mockRequirements);
|
||||
mockCreatePaymentRequiredResponse.mockResolvedValue({
|
||||
x402Version: 2,
|
||||
accepts: mockRequirements,
|
||||
});
|
||||
|
||||
const result = await enforcer.enforce(request);
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyHeaders()", () => {
|
||||
it("sets response headers from EnforceResult", async () => {
|
||||
const enforcer = await freshEnforcer(defaultConfig);
|
||||
const mockResponse = { headers: new Headers() };
|
||||
|
||||
enforcer.applyHeaders(
|
||||
{
|
||||
paid: true,
|
||||
skipped: false,
|
||||
responseHeaders: {
|
||||
"PAYMENT-RESPONSE": "encoded-response",
|
||||
"X-Custom": "value",
|
||||
},
|
||||
},
|
||||
mockResponse,
|
||||
);
|
||||
|
||||
expect(mockResponse.headers.get("PAYMENT-RESPONSE")).toBe("encoded-response");
|
||||
expect(mockResponse.headers.get("X-Custom")).toBe("value");
|
||||
});
|
||||
|
||||
it("is a no-op when there are no response headers", async () => {
|
||||
const enforcer = await freshEnforcer(defaultConfig);
|
||||
const mockResponse = { headers: new Headers() };
|
||||
|
||||
enforcer.applyHeaders({ paid: false, skipped: true, responseHeaders: {} }, mockResponse);
|
||||
|
||||
expect([...mockResponse.headers.entries()]).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasPayment()", () => {
|
||||
it("returns true when payment-signature header is present", async () => {
|
||||
const enforcer = await freshEnforcer(defaultConfig);
|
||||
const request = makeRequest("https://example.com/article", {
|
||||
"payment-signature": "some-sig",
|
||||
});
|
||||
expect(enforcer.hasPayment(request)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when PAYMENT-SIGNATURE header is present (uppercase)", async () => {
|
||||
const enforcer = await freshEnforcer(defaultConfig);
|
||||
const request = makeRequest("https://example.com/article", {
|
||||
"PAYMENT-SIGNATURE": "some-sig",
|
||||
});
|
||||
expect(enforcer.hasPayment(request)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when no payment header is present", async () => {
|
||||
const enforcer = await freshEnforcer(defaultConfig);
|
||||
const request = makeRequest("https://example.com/article");
|
||||
expect(enforcer.hasPayment(request)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
21
packages/x402/tsconfig.json
Normal file
21
packages/x402/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "preserve",
|
||||
"moduleResolution": "bundler",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2022", "DOM"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/middleware.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user