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
34 changes: 34 additions & 0 deletions .github/workflows/sdk-test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# There was no CI running packages/sdk's own tests before this - the repo's
# only prior workflow (test-verify-audit-cid.yml) is scoped exclusively to
# actions/verify-audit-cid/**. Added alongside x402Serve()'s guard feature
# (issue #91) so "tests passing in CI" is a real, checkable fact for this
# package going forward, not just a local run.
name: SDK Tests
on:
pull_request:
paths:
- packages/sdk/**
- .github/workflows/sdk-test.yml
push:
branches:
- main
paths:
- packages/sdk/**
- .github/workflows/sdk-test.yml
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: packages/sdk
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
# --legacy-peer-deps: @stellar/mpp@0.7.1 peer-requires
# @stellar/stellar-sdk@^15.1.0 while this package pins ^14.5.0 - a
# pre-existing mismatch, unrelated to this workflow, not fixed here.
- run: npm install --no-audit --no-fund --legacy-peer-deps
- run: npm run build
- run: npm test
32 changes: 32 additions & 0 deletions examples/deploy-x402-vercel/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,38 @@ A minimal, zero-config template for deploying pay-per-request APIs protected by

---

## Replay/rate-limit protection (on by default in this template)

`x402Serve()` on its own verifies and settles a payment; it does not stop the same payment proof from being replayed, and it does not rate-limit callers - see [nirium-protocol/nirium#91](https://github.com/nirium-protocol/nirium/issues/91). This template turns both on via `x402Serve()`'s `guard` config, using `lib/x402-guard-memory-store.ts` (a plain in-memory store):

```ts
const serveMiddleware = x402Serve({
payTo, network,
routes: { [resource]: { price: priceUsdc, description } },
guard: {
store: defaultGuardStore, // in-memory here, swap for Upstash below
rateLimit: { max: 30, windowMs: 60_000 }, // 30 req/min per IP
},
});
```

A reused `PAYMENT-SIGNATURE` header against the same route gets `409 payment_replayed`; too many requests from one IP get `429 rate_limited`; if the guard's store is down, requests with a payment proof get `503` rather than being let through unprotected.

**The in-memory store is a demo convenience, not production-durable**: Vercel serverless functions aren't guaranteed to reuse the same process between requests, so a replay hitting a different instance than the original won't be caught. For real production use, swap it for Upstash (the store `nirium` ships a reference adapter for):

```ts
import { createUpstashX402GuardStore } from "nirium";

const store = createUpstashX402GuardStore({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});
```

Pass `guard: false` in a route's config to disable it entirely, or set `GUARD_STORE=none` to disable the template's default in-memory store globally.

---

## Testing Your Live Deployment

### 1. Request without payment (402 Payment Required)
Expand Down
43 changes: 43 additions & 0 deletions examples/deploy-x402-vercel/lib/x402-guard-memory-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { X402GuardStore } from "nirium";

/**
* In-memory reference X402GuardStore - for this demo template only.
*
* Vercel serverless functions are NOT guaranteed to reuse the same process
* between invocations (cold starts happen, and traffic can fan out across
* multiple concurrent instances), so this store's protection is
* best-effort here: a replay hitting a different instance than the
* original request won't be caught. It exists to show the shape of
* `config.guard` turned ON without requiring you to set up Upstash just to
* try the template.
*
* For real production use, swap this for `createUpstashX402GuardStore()`
* (exported by the `nirium` package) so every instance shares the same
* durable store - see the README section on this.
*/
export function createMemoryX402GuardStore(): X402GuardStore {
const claims = new Map<string, number>();
const hits = new Map<string, number[]>();

return {
async claimOnce(key, ttlMs) {
const now = Date.now();
const expiresAt = claims.get(key);
if (expiresAt !== undefined && expiresAt > now) return false;
claims.set(key, now + ttlMs);
return true;
},
async release(key) {
claims.delete(key);
},
async slidingWindowHit(key, windowMs, member) {
const now = Date.now();
const windowStart = now - windowMs;
const existing = (hits.get(key) ?? []).filter((ts) => ts > windowStart);
existing.push(now);
hits.set(key, existing);
void member; // unused here - Upstash's ZADD needs a unique member, this doesn't
return existing.length;
},
};
}
31 changes: 30 additions & 1 deletion examples/deploy-x402-vercel/lib/x402-handler.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import { x402Serve } from "nirium";
import { x402Serve, type X402GuardStore } from "nirium";
import { createMemoryX402GuardStore } from "./x402-guard-memory-store.ts";

// One shared store across warm invocations of this module - see the
// caveat in x402-guard-memory-store.ts about cold starts / multiple
// instances. This is what turns x402Serve()'s optional guard ON by
// default in this template; set GUARD_STORE=none to turn it off.
const defaultGuardStore: X402GuardStore | null =
process.env.GUARD_STORE === "none" ? null : createMemoryX402GuardStore();

export type X402RouteConfig = {
payTo?: string;
Expand All @@ -8,6 +16,12 @@ export type X402RouteConfig = {
description?: string;
facilitatorUrl?: string;
facilitatorApiKey?: string;
/**
* Replay/rate-limit protection for x402Serve() - on by default in this
* template (in-memory store, see the caveat above). Pass `false` to
* disable for a single route, or swap `store`/`rateLimit` for your own.
*/
guard?: false | { store?: X402GuardStore; rateLimit?: { max: number; windowMs: number } };
};

/**
Expand Down Expand Up @@ -44,6 +58,16 @@ export function withX402Protection(
req.headers.get("PAYMENT-SIGNATURE") ||
req.headers.get("x-payment-signature");

// Guard is ON by default in this template (config.guard !== false) -
// per-request X-PAYMENT reuse gets 409, and a default rate limit of
// 30 requests/minute per IP applies unless you override it.
const guardConfig = config.guard === false
? undefined
: {
store: config.guard?.store ?? defaultGuardStore ?? undefined,
rateLimit: config.guard?.rateLimit ?? { max: 30, windowMs: 60_000 },
};

const serveMiddleware = x402Serve({
payTo,
network,
Expand All @@ -55,6 +79,7 @@ export function withX402Protection(
},
...(facilitatorUrl ? { facilitatorUrl } : {}),
...(facilitatorApiKey ? { facilitatorApiKey } : {}),
...(guardConfig?.store ? { guard: guardConfig as { store: X402GuardStore; rateLimit?: { max: number; windowMs: number } } } : {}),
});

return new Promise<Response>((resolve) => {
Expand Down Expand Up @@ -104,6 +129,10 @@ export function withX402Protection(
url: req.url,
path: resource,
headers: Object.fromEntries(req.headers.entries()),
// Vercel's Request has no `.ip`; the real client IP (behind Vercel's
// proxy) is this header. Falls back to "unknown" (one shared rate
// limit bucket) if absent, same as a direct/local request.
ip: req.headers.get("x-forwarded-for")?.split(",")[0]?.trim(),
};

if (paymentHeader) {
Expand Down
12 changes: 6 additions & 6 deletions examples/deploy-x402-vercel/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion examples/deploy-x402-vercel/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
},
"dependencies": {
"next": "^15.1.0",
"nirium": "^0.10.1",
"nirium": "^0.14.1",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
Expand Down
11 changes: 11 additions & 0 deletions packages/sdk/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Changelog

All notable changes to the `nirium` package are documented here.

## Unreleased

### Added

- `x402Serve()`: optional `guard` config for replay protection and rate limiting, off by default. Providing `guard.store` turns on single-use protection against a replayed payment proof (`X-PAYMENT`/`PAYMENT-SIGNATURE`); adding `guard.rateLimit` also turns on a sliding-window rate limit per caller IP. Fails closed (503) for replay protection and open for rate limiting if the store is unavailable, matching the design already proven in a real production `x402Serve()` deployment. New exports: `X402GuardStore`, `X402GuardConfig`, `createUpstashX402GuardStore()` (a reference Upstash-backed store). See [#91](https://github.com/nirium-protocol/nirium/issues/91).

The store interface and this feature's fail-closed/fail-open split are generalized from - and credited to - a real production integrator's own implementation: **Edgadafi/remesa-liquidez** (commit `1e0cbd5902cb224d3c6a2320cc009cf4df84f513`, `backend/src/middleware/paymentGuard.ts` and `backend/src/middleware/rateLimit.ts`). The code in this package is our own, not copied from theirs.
39 changes: 39 additions & 0 deletions packages/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { createEd25519Signer } from '@x402/stellar';
// @ts-ignore
import { ExactStellarScheme } from '@x402/stellar/exact/client';
import * as MppxModule from 'mppx';
import { checkReplay, checkRateLimit, type X402GuardConfig } from './x402-guard';

export interface AgentConfig {
apiKey: string;
Expand Down Expand Up @@ -1300,6 +1301,13 @@ export interface X402ServeConfig {
facilitatorAuthHeader?: string;
appName?: string;
appLogo?: string;
/**
* Optional replay/rate-limit protection - off by default. Providing
* `guard.store` turns it on; without it, x402Serve() behaves exactly
* as before. See x402-guard.ts and
* https://github.com/nirium-protocol/nirium/issues/91.
*/
guard?: X402GuardConfig;
}

const X402_FACILITATORS = {
Expand Down Expand Up @@ -1482,8 +1490,36 @@ export function x402Serve(config: X402ServeConfig): any {
lastFlush = Date.now();
};

const sendDenied = (res: any, d: { status: number; body: Record<string, unknown>; headers?: Record<string, string> }) => {
if (d.headers) for (const [k, v] of Object.entries(d.headers)) res.setHeader(k, v);
res.status(d.status).json(d.body);
};

return async function niriumX402(req: any, res: any, next: any) {
maybeFlushHeartbeat();

if (config.guard) {
const guardReq = { method: req.method, url: req.originalUrl ?? req.url, headers: req.headers, ip: req.ip };

const rl = await checkRateLimit(guardReq, config.guard);
if (!rl.allowed) {
sendDenied(res, rl.denied!);
return;
}

const replay = await checkReplay(guardReq, config.guard);
if (!replay.allowed) {
sendDenied(res, replay.denied!);
return;
}
if (replay.release) {
const release = replay.release;
res.on('finish', () => {
if (res.statusCode < 200 || res.statusCode >= 300) release();
});
}
}

try {
if (!cached) {
await preflight();
Expand All @@ -1502,6 +1538,9 @@ export function x402Serve(config: X402ServeConfig): any {
// (unlike a bundler) needs the real extension to find the compiled file.
export { x402Metrics } from './metrics';
export type { X402MetricsResult, MetricsSnapshot } from './metrics';
export type { X402GuardStore, X402GuardConfig, GuardRequest, GuardDenied } from './x402-guard';
export { createUpstashX402GuardStore } from './x402-guard-upstash';
export type { UpstashX402GuardStoreOptions } from './x402-guard-upstash';

export default Agent;
export {
Expand Down
50 changes: 50 additions & 0 deletions packages/sdk/src/x402-guard-upstash.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* Reference X402GuardStore backed by Upstash's REST API — plain `fetch`,
* no extra dependency. Any other Redis-compatible store can implement the
* three-method X402GuardStore interface instead; this one is provided
* because it's the store the production reference implementation this
* feature is modeled on actually uses. See x402-guard.ts.
*/

import type { X402GuardStore } from './x402-guard';

export interface UpstashX402GuardStoreOptions {
/** e.g. https://usw1-xxxx.upstash.io */
url: string;
token: string;
}

export function createUpstashX402GuardStore(opts: UpstashX402GuardStoreOptions): X402GuardStore {
const base = opts.url.replace(/\/$/, '');

const call = async (cmd: (string | number)[]): Promise<any> => {
const r = await fetch(base, {
method: 'POST',
headers: { Authorization: `Bearer ${opts.token}`, 'Content-Type': 'application/json' },
body: JSON.stringify(cmd),
signal: AbortSignal.timeout(5000),
});
if (!r.ok) throw new Error(`Upstash returned HTTP ${r.status}`);
const j = await r.json();
if (j.error) throw new Error(`Upstash error: ${j.error}`);
return j.result;
};

return {
async claimOnce(key, ttlMs) {
const result = await call(['SET', key, '1', 'PX', ttlMs, 'NX']);
return result === 'OK';
},
async release(key) {
await call(['DEL', key]);
},
async slidingWindowHit(key, windowMs, member) {
const now = Date.now();
await call(['ZADD', key, now, member]);
await call(['ZREMRANGEBYSCORE', key, 0, now - windowMs]);
await call(['PEXPIRE', key, windowMs]);
const count = await call(['ZCARD', key]);
return Number(count);
},
};
}
Loading
Loading