Skip to content
Open
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
217 changes: 217 additions & 0 deletions bun.lock

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@
"@hono/zod-validator": "^0.7.5",
"@pinax/graph-networks-registry": "^0.7.1",
"@web3icons/core": "^4.0.18",
"@x402/core": "^2.11.0",
"@x402/evm": "^2.11.0",
"@x402/extensions": "^2.11.0",
"@x402/hono": "^2.11.0",
"@x402/svm": "^2.11.0",
"commander": "^14.0.0",
"dotenv": "^17.2.1",
"hono": "^4.8.12",
Expand Down
72 changes: 72 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,16 @@ export const DEFAULT_CACHE_SERVER_MAX_AGE = 600; // s-maxage for shared/proxy ca
export const DEFAULT_CACHE_MAX_AGE = 60; // max-age for browser caches
export const DEFAULT_CACHE_STALE_WHILE_REVALIDATE = 30; // RFC 5861 stale-while-revalidate window
export const DEFAULT_PLANS = '';
export const DEFAULT_X402_ENABLED = false;
export const DEFAULT_X402_FACILITATOR_URL = 'https://api.cdp.coinbase.com/platform/v2/x402';
export const DEFAULT_X402_EVM_NETWORK = 'eip155:8453';
export const DEFAULT_X402_SVM_NETWORK = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp';
export const DEFAULT_X402_EVM_PAY_TO = '0x49D581486438aAD93f4114084Ac5B09A8b7C9685';
export const DEFAULT_X402_SVM_PAY_TO = 'EpRR35QnB5PfTczy3j5rp9bCw4NKzHkd8S1ubdza4my9';
export const DEFAULT_X402_PASS_PRICE = '$0.10';
export const DEFAULT_X402_PASS_DURATION_SECONDS = 3600;
export const DEFAULT_X402_PLAN = 'pro';
export const DEFAULT_X402_SYNC_FACILITATOR_ON_START = true;

// GitHub metadata
const GIT_COMMIT = (process.env.GIT_COMMIT ?? (await $`git rev-parse HEAD`.text())).replace(/\n/, '').slice(0, 7);
Expand Down Expand Up @@ -255,6 +265,58 @@ const opts = program
.env('PLANS')
.default(DEFAULT_PLANS)
)
.addOption(
new Option('--x402-enabled <boolean>', 'Enable x402 access pass payment middleware')
.choices(['true', 'false'])
.env('X402_ENABLED')
.default(DEFAULT_X402_ENABLED)
)
.addOption(
new Option('--x402-facilitator-url <string>', 'x402 facilitator HTTP URL')
.env('X402_FACILITATOR_URL')
.default(DEFAULT_X402_FACILITATOR_URL)
)
.addOption(
new Option('--x402-evm-network <string>', 'CAIP-2 EVM network accepted for x402 payments')
.env('X402_EVM_NETWORK')
.default(DEFAULT_X402_EVM_NETWORK)
)
.addOption(
new Option('--x402-svm-network <string>', 'CAIP-2 SVM network accepted for x402 payments')
.env('X402_SVM_NETWORK')
.default(DEFAULT_X402_SVM_NETWORK)
)
.addOption(
new Option('--x402-evm-pay-to <string>', 'EVM payment recipient for x402 access passes')
.env('X402_EVM_PAY_TO')
.default(DEFAULT_X402_EVM_PAY_TO)
)
.addOption(
new Option('--x402-svm-pay-to <string>', 'SVM payment recipient for x402 access passes')
.env('X402_SVM_PAY_TO')
.default(DEFAULT_X402_SVM_PAY_TO)
)
.addOption(
new Option('--x402-pass-price <string>', 'USD-denominated x402 access pass price')
.env('X402_PASS_PRICE')
.default(DEFAULT_X402_PASS_PRICE)
)
.addOption(
new Option('--x402-pass-duration-seconds <number>', 'x402 access pass duration in seconds')
.env('X402_PASS_DURATION_SECONDS')
.default(DEFAULT_X402_PASS_DURATION_SECONDS)
)
.addOption(
new Option('--x402-plan <string>', 'Plan applied to requests with valid x402 payment access')
.env('X402_PLAN')
.default(DEFAULT_X402_PLAN)
)
.addOption(
new Option('--x402-sync-facilitator-on-start <boolean>', 'Sync x402 facilitator support before first request')
.choices(['true', 'false'])
.env('X402_SYNC_FACILITATOR_ON_START')
.default(DEFAULT_X402_SYNC_FACILITATOR_ON_START)
)
.allowUnknownOption()
.allowExcessArguments()
.parse()
Expand Down Expand Up @@ -294,6 +356,16 @@ const config = z
cacheMaxAge: z.coerce.number().nonnegative('Cache max-age must be non-negative'),
cacheStaleWhileRevalidate: z.coerce.number().nonnegative('Cache stale-while-revalidate must be non-negative'),
plans: z.string().transform(parsePlans),
x402Enabled: z.coerce.string().transform((val) => val.toLowerCase() === 'true'),
x402FacilitatorUrl: z.string().url({ message: 'Invalid x402 facilitator URL' }),
x402EvmNetwork: z.string().min(1, 'x402 EVM network cannot be empty'),
x402SvmNetwork: z.string().min(1, 'x402 SVM network cannot be empty'),
x402EvmPayTo: z.string().min(1, 'x402 EVM pay-to address cannot be empty'),
x402SvmPayTo: z.string().min(1, 'x402 SVM pay-to address cannot be empty'),
x402PassPrice: z.string().regex(/^\$\d+(\.\d+)?$/, 'x402 pass price must be a USD amount like $0.10'),
x402PassDurationSeconds: z.coerce.number().positive('x402 pass duration must be positive'),
x402Plan: z.string().min(1, 'x402 plan cannot be empty'),
x402SyncFacilitatorOnStart: z.coerce.string().transform((val) => val.toLowerCase() === 'true'),
})
.transform((data) => {
// Use YAML config as the authoritative source — spread all database maps dynamically
Expand Down
40 changes: 40 additions & 0 deletions src/middleware/cacheControl.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'bun:test';
import { Hono } from 'hono';
import { config } from '../config.js';
import { cacheControl } from './cacheControl.js';

describe('cacheControl', () => {
it('sets public cache headers on successful cacheable responses', async () => {
const previousDisable = config.cacheDisable;
config.cacheDisable = false;

const app = new Hono();
app.use('*', cacheControl());
app.get('/', (ctx) => ctx.text('ok'));

const response = await app.request('/');

expect(response.headers.get('Cache-Control')).toContain('public');

config.cacheDisable = previousDisable;
});

it('does not set public cache headers for x402 paid requests', async () => {
const previousDisable = config.cacheDisable;
config.cacheDisable = false;

const app = new Hono();
app.use('*', cacheControl());
app.get('/', (ctx) => ctx.text('ok'));

const response = await app.request('/', {
headers: {
'PAYMENT-SIGNATURE': 'signature',
},
});

expect(response.headers.get('Cache-Control')).toBeNull();

config.cacheDisable = previousDisable;
});
});
11 changes: 11 additions & 0 deletions src/middleware/cacheControl.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Context, Next } from 'hono';
import { config } from '../config.js';
import { hasPaymentHeader, LEGACY_PAYMENT_RESPONSE_HEADER, PAYMENT_RESPONSE_HEADER } from '../x402/accessPass.js';

/**
* Hono middleware that adds HTTP Cache-Control headers to cacheable responses.
Expand All @@ -10,6 +11,7 @@ import { config } from '../config.js';
* Behaviour:
* - When `CACHE_DISABLE=true`, no cache headers are emitted.
* - When the request includes `Cache-Control: no-cache`, no cache headers are emitted.
* - When the request/response carries x402 payment state, no shared cache headers are emitted.
*
* Note: ETag/If-None-Match is intentionally omitted — response bodies include dynamic
* metadata (request_time, duration_ms, statistics) that change on every request, making
Expand All @@ -35,6 +37,15 @@ export function cacheControl() {
// Skip cache headers if client requests no-cache
if (ctx.req.header('Cache-Control') === 'no-cache') return;

// Skip cache headers for paid responses; access is tied to payment receipt state.
if (
hasPaymentHeader(ctx.req.raw.headers) ||
ctx.res.headers.has(PAYMENT_RESPONSE_HEADER) ||
ctx.res.headers.has(LEGACY_PAYMENT_RESPONSE_HEADER)
) {
return;
}

// Only cache successful responses
if (ctx.res.status !== 200) return;

Expand Down
6 changes: 6 additions & 0 deletions src/routes/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Hono } from 'hono';
import { cacheControl } from '../middleware/cacheControl.js';
import { normalizeProtocolQuery } from '../middleware/normalizeProtocolQuery.js';
import { x402PaymentMiddleware, x402PlanHeaderMiddleware } from '../x402/middleware.js';
// Balances
import evmBalances from './balances/evm.js';
// import tvmBalancesNative from './balances/tvm_native.js';
Expand Down Expand Up @@ -79,6 +80,11 @@ const router = new Hono();
// Normalize request query parameters before validation/route handling.
router.use('/v1/*', normalizeProtocolQuery);

// --- x402 payment middleware ---
// Protects paid API routes when enabled; free monitoring/discovery endpoints remain outside x402.
router.use('/v1/*', x402PlanHeaderMiddleware);
router.use('/v1/*', x402PaymentMiddleware);

// --- HTTP Cache-Control middleware ---
// Default: all /v1/* routes get a minimal 1s cache (no SWR).
// Specific routes below override with longer env-configured TTLs.
Expand Down
91 changes: 91 additions & 0 deletions src/x402/accessPass.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { describe, expect, it } from 'bun:test';
import { encodePaymentSignatureHeader } from '@x402/core/http';
import type { Network, PaymentPayload, SettleResponse } from '@x402/core/types';
import { PAYMENT_IDENTIFIER } from '@x402/extensions/payment-identifier';
import {
cacheSettledAccessPass,
getPaymentHeader,
getPaymentIdentifierFromHeader,
X402AccessPassStore,
x402AccessPassStore,
} from './accessPass.js';

const paymentPayload: PaymentPayload = {
x402Version: 2,
accepted: {
scheme: 'exact',
network: 'eip155:8453' as Network,
asset: 'USDC',
amount: '100000',
payTo: '0x49D581486438aAD93f4114084Ac5B09A8b7C9685',
maxTimeoutSeconds: 120,
extra: {},
},
payload: {},
extensions: {
[PAYMENT_IDENTIFIER]: {
info: {
required: true,
id: 'pay_1234567890abcdef',
},
},
},
};

const settleResponse: SettleResponse = {
success: true,
transaction: '0xsettled',
network: 'eip155:8453' as Network,
amount: '100000',
payer: '0xpayer',
};

describe('X402AccessPassStore', () => {
it('returns a valid pass and prunes expired passes', () => {
const store = new X402AccessPassStore();

store.set({
id: 'pay_1234567890abcdef',
transaction: '0xsettled',
network: 'eip155:8453',
createdAt: 1_000,
expiresAt: 2_000,
});

expect(store.get('pay_1234567890abcdef', 1_500)?.transaction).toBe('0xsettled');
expect(store.get('pay_1234567890abcdef', 2_000)).toBeUndefined();
expect(store.size(2_000)).toBe(0);
});
});

describe('x402 access pass helpers', () => {
it('extracts the payment identifier from a payment signature header', () => {
const header = encodePaymentSignatureHeader(paymentPayload);

expect(getPaymentIdentifierFromHeader(header)).toBe('pay_1234567890abcdef');
});

it('returns undefined for malformed payment headers', () => {
expect(getPaymentIdentifierFromHeader('not-base64-json')).toBeUndefined();
});

it('reads both v2 and legacy payment headers', () => {
const headers = new Headers({ 'X-PAYMENT': encodePaymentSignatureHeader(paymentPayload) });

expect(getPaymentHeader(headers)).toBeDefined();
});

it('caches settled payments for the configured duration', () => {
x402AccessPassStore.clear();

const pass = cacheSettledAccessPass(paymentPayload, settleResponse, 3600, 1_000);

expect(pass).toMatchObject({
id: 'pay_1234567890abcdef',
transaction: '0xsettled',
expiresAt: 3_601_000,
});
expect(x402AccessPassStore.get('pay_1234567890abcdef', 3_600_999)).toBeDefined();
x402AccessPassStore.clear();
});
});
108 changes: 108 additions & 0 deletions src/x402/accessPass.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { decodePaymentSignatureHeader } from '@x402/core/http';
import type { PaymentPayload, SettleResponse } from '@x402/core/types';
import { extractPaymentIdentifier } from '@x402/extensions/payment-identifier';

export const PAYMENT_SIGNATURE_HEADER = 'PAYMENT-SIGNATURE';
export const LEGACY_PAYMENT_HEADER = 'X-PAYMENT';
export const PAYMENT_RESPONSE_HEADER = 'PAYMENT-RESPONSE';
export const LEGACY_PAYMENT_RESPONSE_HEADER = 'X-PAYMENT-RESPONSE';

export type X402AccessPass = {
id: string;
payer?: string;
transaction: string;
network: string;
amount?: string;
expiresAt: number;
createdAt: number;
};

export class X402AccessPassStore {
private passes = new Map<string, X402AccessPass>();

get(id: string, nowMs = Date.now()) {
const pass = this.passes.get(id);

if (!pass) return undefined;

if (pass.expiresAt <= nowMs) {
this.passes.delete(id);
return undefined;
}

return pass;
}

set(pass: X402AccessPass) {
this.passes.set(pass.id, pass);
}

size(nowMs = Date.now()) {
this.prune(nowMs);
return this.passes.size;
}

clear() {
this.passes.clear();
}

prune(nowMs = Date.now()) {
for (const [id, pass] of this.passes.entries()) {
if (pass.expiresAt <= nowMs) this.passes.delete(id);
}
}
}

export const x402AccessPassStore = new X402AccessPassStore();

export function getPaymentHeader(headers: Headers | { get(name: string): string | null | undefined }) {
return headers.get(PAYMENT_SIGNATURE_HEADER) ?? headers.get(LEGACY_PAYMENT_HEADER) ?? undefined;
}

export function hasPaymentHeader(headers: Headers | { get(name: string): string | null | undefined }) {
return getPaymentHeader(headers) !== undefined;
}

export function getPaymentIdentifierFromPayload(paymentPayload: PaymentPayload) {
return extractPaymentIdentifier(paymentPayload, true) ?? undefined;
}

export function getPaymentPayloadFromHeader(paymentHeader: string) {
try {
return decodePaymentSignatureHeader(paymentHeader);
} catch {
return undefined;
}
}

export function getPaymentIdentifierFromHeader(paymentHeader: string) {
const paymentPayload = getPaymentPayloadFromHeader(paymentHeader);
if (!paymentPayload) return undefined;

return getPaymentIdentifierFromPayload(paymentPayload);
}

export function cacheSettledAccessPass(
paymentPayload: PaymentPayload,
settleResult: Readonly<SettleResponse>,
durationSeconds: number,
nowMs = Date.now()
) {
if (!settleResult.success) return undefined;

const id = getPaymentIdentifierFromPayload(paymentPayload);
if (!id) return undefined;

const pass: X402AccessPass = {
id,
payer: settleResult.payer,
transaction: settleResult.transaction,
network: settleResult.network,
amount: settleResult.amount,
createdAt: nowMs,
expiresAt: nowMs + durationSeconds * 1000,
};

x402AccessPassStore.set(pass);
return pass;
}
Loading
Loading