diff --git a/services/appraisal-api/README.md b/services/appraisal-api/README.md index 0385a6b..a4e3754 100644 --- a/services/appraisal-api/README.md +++ b/services/appraisal-api/README.md @@ -71,6 +71,22 @@ A `POST /appraise` body larger than 8192 bytes is rejected with **HTTP 413** `{ "error": "payload_too_large", "limitBytes": 8192 }` before the bytes are parsed as JSON, so the request never receives a 402 payment quote. +### Quote rate limiting & production adapter boundary + +Unpaid `POST /appraise` quote creation requests are rate-limited before a `402` +quote is minted. Exceeding the limit returns **HTTP 429** +`{ "error": "rate_limit_exceeded", "retryAfterSeconds": }` with +`Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and +`X-RateLimit-Reset` headers. + +- **Development default (`InMemoryQuoteRateLimiter`)**: Fixed-window in-memory + limiter (default `30` requests per `60_000` ms window, capped at `1_024` + tracked keys with expired/oldest eviction so memory stays strictly bounded). +- **Production adapter boundary (`AppraisalQuoteRateLimiter`)**: Pass a + distributed store adapter via `buildAppraisalServer(config, { quoteRateLimiter })` + implementing `consume(key, nowMs)` (for example backed by Redis or an edge + token bucket) when running multiple server instances behind a load balancer. + ## Schema fixtures The canonical request/response shapes and the per-failure-mode payloads live as diff --git a/services/appraisal-api/src/appraisal.ts b/services/appraisal-api/src/appraisal.ts index fb3f300..021300f 100644 --- a/services/appraisal-api/src/appraisal.ts +++ b/services/appraisal-api/src/appraisal.ts @@ -62,7 +62,7 @@ export interface AppraisalRequest { } export interface Appraisal { - model: typeof APPRAISALMODEL; + model: typeof APPRAISAL_MODEL; itemRef: string; inputsHash: string; fairValue: number; @@ -93,7 +93,7 @@ function canonical(value: unknown): string { const entries = Object.entries(value as Record) .filter(([, v]) => v !== undefined) .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) - .map(([k], v]) => `${JSON.stringify(k)}:${canonical(v)}`); + .map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`); return `{${entries.join(",")}}`; } return JSON.stringify(value ?? null); @@ -275,7 +275,7 @@ export function appraise(req: AppraisalRequest): Appraisal { const suggestedMaxBid = round2(fairValue * (0.8 + 0.15 * confidence)); const rationale = [ - `base ${req.basePrice} USDC scaled by quality×${round2(qualityF)}, demand×ä{round2(demandF)}, scarcity×ä{round2(scarcityF)}, risk×ä{round2(riskF)}`, + `base ${req.basePrice} USDC scaled by quality×${round2(qualityF)}, demand×${round2(demandF)}, scarcity×${round2(scarcityF)}, risk×${round2(riskF)}`, `category '${req.category ?? "none"}' multiplier ×${categoryF}`, `${provided}/4 attributes supplied → confidence ${confidence}`, `suggested max bid is fair value × ${round2(0.8 + 0.15 * confidence)} to preserve margin`, diff --git a/services/appraisal-api/src/body-limit.test.ts b/services/appraisal-api/src/body-limit.test.ts index 7fd4e1d..427edf7 100644 --- a/services/appraisal-api/src/body-limit.test.ts +++ b/services/appraisal-api/src/body-limit.test.ts @@ -6,10 +6,20 @@ import { once } from "node:events"; import { PassThrough } from "node:stream"; import { describe, test } from "node:test"; +import { Keypair, Networks } from "@stellar/stellar-sdk"; +import { STELLAR_TESTNET_CAIP2, USDC_TESTNET_ADDRESS } from "@x402/stellar"; + import { APPRAISAL_BODY_TOO_LARGE, takeAppraisalRequestBody, } from "./body-limit.js"; +import { + APPRAISAL_RATE_LIMITED, + InMemoryQuoteRateLimiter, + type AppraisalQuoteRateLimiter, +} from "./rate-limit.js"; +import { buildAppraisalServer } from "./server.js"; +import { validRequest } from "./fixtures/index.js"; function listen(server: http.Server): Promise { server.listen(0, "127.0.0.1"); @@ -24,7 +34,7 @@ function listen(server: http.Server): Promise { function fakeRequest(headers: http.IncomingHttpHeaders, body: Buffer): IncomingMessage { const stream = new PassThrough(); - const req = Object.assign(stream, { headers }) as IncomingMessage; + const req = Object.assign(stream, { headers }) as unknown as IncomingMessage; queueMicrotask(() => { stream.end(body); }); @@ -75,6 +85,110 @@ describe("appraisal request body cap", () => { }); assert.equal(allowed.status, 200); } finally { + server.closeAllConnections?.(); + server.close(); + } + }); +}); + +describe("appraisal quote rate limiting", () => { + test("InMemoryQuoteRateLimiter enforces window quota and resets deterministically", () => { + let now = 1_000_000; + const limiter = new InMemoryQuoteRateLimiter({ + maxRequests: 2, + windowMs: 10_000, + maxKeys: 4, + nowMs: () => now, + }); + + const first = limiter.consume("127.0.0.1"); + assert.equal(first.allowed, true); + assert.equal(first.remaining, 1); + + const second = limiter.consume("127.0.0.1"); + assert.equal(second.allowed, true); + assert.equal(second.remaining, 0); + + now += 3_000; + const third = limiter.consume("127.0.0.1"); + assert.equal(third.allowed, false); + assert.equal(third.remaining, 0); + assert.equal(third.retryAfterSeconds, 7); + + now += 7_000; + const afterReset = limiter.consume("127.0.0.1"); + assert.equal(afterReset.allowed, true); + assert.equal(afterReset.remaining, 1); + }); + + test("InMemoryQuoteRateLimiter bounds memory to maxKeys under key churn", () => { + let now = 2_000_000; + const limiter = new InMemoryQuoteRateLimiter({ + maxRequests: 5, + windowMs: 60_000, + maxKeys: 3, + nowMs: () => now, + }); + + for (let i = 0; i < 10; i++) { + limiter.consume(`client-${i}`); + assert.ok(limiter.size <= 3, `expected size <= 3, got ${limiter.size}`); + } + }); + + test("POST /appraise enforces quote rate limits and supports custom production adapter", async () => { + const facilitator = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 7)); + const payee = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 8)); + let calls = 0; + const customAdapter: AppraisalQuoteRateLimiter = { + async consume() { + calls += 1; + return { + allowed: calls <= 1, + limit: 1, + remaining: 0, + retryAfterSeconds: calls <= 1 ? 0 : 15, + resetAtMs: 1_700_000_015_000, + }; + }, + }; + + const server = await buildAppraisalServer( + { + facilitatorSecret: facilitator.secret(), + payTo: payee.publicKey(), + asset: USDC_TESTNET_ADDRESS, + price: 0.1, + network: STELLAR_TESTNET_CAIP2, + networkPassphrase: Networks.TESTNET, + rpcUrl: "https://soroban-testnet.stellar.org", + port: 0, + }, + { quoteRateLimiter: customAdapter }, + ); + const port = await listen(server); + try { + const first = await fetch(`http://127.0.0.1:${port}/appraise`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(validRequest.value), + }); + assert.equal(first.status, 402); + assert.equal(first.headers.get("x-ratelimit-limit"), "1"); + + const second = await fetch(`http://127.0.0.1:${port}/appraise`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(validRequest.value), + }); + assert.equal(second.status, 429); + assert.equal(second.headers.get("retry-after"), "15"); + assert.deepEqual(await second.json(), { + error: APPRAISAL_RATE_LIMITED, + retryAfterSeconds: 15, + }); + } finally { + server.closeAllConnections?.(); server.close(); } }); diff --git a/services/appraisal-api/src/index.ts b/services/appraisal-api/src/index.ts index 9198c6a..7683f88 100644 --- a/services/appraisal-api/src/index.ts +++ b/services/appraisal-api/src/index.ts @@ -18,7 +18,23 @@ export { type AppraisalAttributes, } from "./appraisal.js"; -export { buildAppraisalServer } from "./server.js"; +export { + buildAppraisalServer, + type AppraisalServerOptions, +} from "./server.js"; +export { + APPRAISAL_RATE_LIMITED, + DEFAULT_QUOTE_RATE_LIMIT_MAX_KEYS, + DEFAULT_QUOTE_RATE_LIMIT_MAX_REQUESTS, + DEFAULT_QUOTE_RATE_LIMIT_WINDOW_MS, + InMemoryQuoteRateLimiter, + appraisalRateLimitedBody, + rateLimitHeaders, + resolveQuoteRateLimitKey, + type AppraisalQuoteRateLimitDecision, + type AppraisalQuoteRateLimiter, + type InMemoryQuoteRateLimiterOptions, +} from "./rate-limit.js"; export { AppraisalConfigError, configFromEnv, diff --git a/services/appraisal-api/src/rate-limit.ts b/services/appraisal-api/src/rate-limit.ts new file mode 100644 index 0000000..06eaa30 --- /dev/null +++ b/services/appraisal-api/src/rate-limit.ts @@ -0,0 +1,186 @@ +// Copyright (c) 2026 Sub Rosa contributors +import type { IncomingMessage } from "node:http"; + +/** Stable error code returned when appraisal quote creation exceeds the rate limit. */ +export const APPRAISAL_RATE_LIMITED = "rate_limit_exceeded"; + +/** Default max quote creations per window per client key in development mode. */ +export const DEFAULT_QUOTE_RATE_LIMIT_MAX_REQUESTS = 30; + +/** Default rate-limit window duration in milliseconds (60 seconds). */ +export const DEFAULT_QUOTE_RATE_LIMIT_WINDOW_MS = 60_000; + +/** Default cap on tracked keys in the in-memory development limiter. */ +export const DEFAULT_QUOTE_RATE_LIMIT_MAX_KEYS = 1_024; + +export interface AppraisalQuoteRateLimitDecision { + allowed: boolean; + limit: number; + remaining: number; + retryAfterSeconds: number; + resetAtMs: number; +} + +/** + * Production adapter boundary for appraisal quote creation rate limiting. + * + * Local and development runs default to {@link InMemoryQuoteRateLimiter}. + * Multi-instance production deployments should inject a shared store adapter + * (e.g. Redis, Cloudflare Durable Objects, or API gateway token bucket) + * implementing this interface. + */ +export interface AppraisalQuoteRateLimiter { + consume( + key: string, + nowMs?: number, + ): AppraisalQuoteRateLimitDecision | Promise; +} + +export interface InMemoryQuoteRateLimiterOptions { + /** Max allowed requests per window per key. Default: 30. */ + maxRequests?: number; + /** Window length in milliseconds. Default: 60_000. */ + windowMs?: number; + /** Maximum distinct keys retained in memory at once. Default: 1_024. */ + maxKeys?: number; + /** Injectable clock returning epoch milliseconds. */ + nowMs?: () => number; +} + +interface BucketEntry { + count: number; + resetAtMs: number; +} + +/** + * Bounded in-memory fixed-window rate limiter for local/development servers. + * Evicts expired windows first and then evicts the oldest key when `maxKeys` + * is reached so memory usage remains strictly bounded under key-churn attacks. + */ +export class InMemoryQuoteRateLimiter implements AppraisalQuoteRateLimiter { + readonly maxRequests: number; + readonly windowMs: number; + readonly maxKeys: number; + readonly #nowMs: () => number; + readonly #buckets = new Map(); + + constructor(options: InMemoryQuoteRateLimiterOptions = {}) { + const maxRequests = options.maxRequests ?? DEFAULT_QUOTE_RATE_LIMIT_MAX_REQUESTS; + const windowMs = options.windowMs ?? DEFAULT_QUOTE_RATE_LIMIT_WINDOW_MS; + const maxKeys = options.maxKeys ?? DEFAULT_QUOTE_RATE_LIMIT_MAX_KEYS; + + if (!Number.isInteger(maxRequests) || maxRequests < 1) { + throw new Error("maxRequests must be a positive integer"); + } + if (!Number.isInteger(windowMs) || windowMs < 1) { + throw new Error("windowMs must be a positive integer"); + } + if (!Number.isInteger(maxKeys) || maxKeys < 1) { + throw new Error("maxKeys must be a positive integer"); + } + + this.maxRequests = maxRequests; + this.windowMs = windowMs; + this.maxKeys = maxKeys; + this.#nowMs = options.nowMs ?? (() => globalThis.Date.now()); + } + + /** Current number of active keys tracked in memory. */ + get size(): number { + return this.#buckets.size; + } + + consume(key: string, nowMs = this.#nowMs()): AppraisalQuoteRateLimitDecision { + const normalizedKey = key.trim() || "unknown"; + const existing = this.#buckets.get(normalizedKey); + + if (existing && nowMs < existing.resetAtMs) { + if (existing.count >= this.maxRequests) { + const retryAfterSeconds = Math.max( + 1, + Math.ceil((existing.resetAtMs - nowMs) / 1000), + ); + return { + allowed: false, + limit: this.maxRequests, + remaining: 0, + retryAfterSeconds, + resetAtMs: existing.resetAtMs, + }; + } + existing.count += 1; + return { + allowed: true, + limit: this.maxRequests, + remaining: Math.max(0, this.maxRequests - existing.count), + retryAfterSeconds: 0, + resetAtMs: existing.resetAtMs, + }; + } + + if (existing) { + this.#buckets.delete(normalizedKey); + } + + if (this.#buckets.size >= this.maxKeys) { + this.#evictExpiredOrOldest(nowMs); + } + + const resetAtMs = nowMs + this.windowMs; + this.#buckets.set(normalizedKey, { count: 1, resetAtMs }); + return { + allowed: true, + limit: this.maxRequests, + remaining: Math.max(0, this.maxRequests - 1), + retryAfterSeconds: 0, + resetAtMs, + }; + } + + #evictExpiredOrOldest(nowMs: number): void { + for (const [k, entry] of this.#buckets.entries()) { + if (nowMs >= entry.resetAtMs) { + this.#buckets.delete(k); + } + } + while (this.#buckets.size >= this.maxKeys) { + const oldestKey = this.#buckets.keys().next().value; + if (oldestKey === undefined) break; + this.#buckets.delete(oldestKey); + } + } +} + +/** Resolve a stable client key from the socket remote address. */ +export function resolveQuoteRateLimitKey( + req: Pick, +): string { + const remote = req.socket?.remoteAddress?.trim(); + return remote && remote.length > 0 ? remote : "unknown"; +} + +export function rateLimitHeaders( + decision: AppraisalQuoteRateLimitDecision, +): Record { + const headers: Record = { + "x-ratelimit-limit": String(decision.limit), + "x-ratelimit-remaining": String(decision.remaining), + "x-ratelimit-reset": String(Math.ceil(decision.resetAtMs / 1000)), + }; + if (!decision.allowed) { + headers["retry-after"] = String(decision.retryAfterSeconds); + } + return headers; +} + +export function appraisalRateLimitedBody( + decision: Pick, +): { + error: typeof APPRAISAL_RATE_LIMITED; + retryAfterSeconds: number; +} { + return { + error: APPRAISAL_RATE_LIMITED, + retryAfterSeconds: decision.retryAfterSeconds, + }; +} diff --git a/services/appraisal-api/src/server.ts b/services/appraisal-api/src/server.ts index 504f59e..423a0a3 100644 --- a/services/appraisal-api/src/server.ts +++ b/services/appraisal-api/src/server.ts @@ -40,9 +40,27 @@ import { ExactStellarScheme as ServerStellarScheme } from "@x402/stellar/exact/s import { appraise, AppraisalInputError, buildAppraisalQuote, MAX_APPRAISAL_BODY_BYTES, parseAppraisalRequest } from "./appraisal.js"; import { takeAppraisalRequestBody } from "./body-limit.js"; import type { AppraisalServerConfig } from "./config.js"; +import { + appraisalRateLimitedBody, + InMemoryQuoteRateLimiter, + rateLimitHeaders, + resolveQuoteRateLimitKey, + type AppraisalQuoteRateLimiter, +} from "./rate-limit.js"; const APPRAISE_ROUTE = "POST /appraise"; +export interface AppraisalServerOptions { + /** + * Optional quote creation rate limiter. Defaults to a bounded + * {@link InMemoryQuoteRateLimiter}; production deployments can inject a + * distributed adapter implementing {@link AppraisalQuoteRateLimiter}. + */ + quoteRateLimiter?: AppraisalQuoteRateLimiter; + /** Optional clock override returning epoch milliseconds. */ + nowMs?: () => number; +} + /** Minimal HTTPAdapter over a Node request + already-read body. */ function makeAdapter( req: http.IncomingMessage, @@ -92,8 +110,12 @@ function writeInstructions(res: http.ServerResponse, i: HTTPResponseInstructions /** Build (and initialize) the x402-gated appraisal HTTP server. */ export async function buildAppraisalServer( config: AppraisalServerConfig, + options: AppraisalServerOptions = {}, ): Promise { const rpcConfig = config.rpcUrl ? { url: config.rpcUrl } : undefined; + const quoteRateLimiter = + options.quoteRateLimiter ?? + new InMemoryQuoteRateLimiter({ nowMs: options.nowMs }); // In-process facilitator: verifies + settles on-chain (sponsors fees, submits). const facilitatorSigner = createEd25519Signer(config.facilitatorSecret, config.network); @@ -157,6 +179,26 @@ export async function buildAppraisalServer( }); } + const paymentHeader = req.headers["x-payment"]; + const hasPaymentHeader = + typeof paymentHeader === "string" + ? paymentHeader.trim().length > 0 + : Array.isArray(paymentHeader) && paymentHeader.some((v) => v.trim().length > 0); + + let quoteLimitHeaders: Record = {}; + if (method === "POST" && url.pathname === "/appraise" && !hasPaymentHeader) { + const decision = await quoteRateLimiter.consume( + resolveQuoteRateLimitKey(req), + options.nowMs?.(), + ); + quoteLimitHeaders = rateLimitHeaders(decision); + if (!decision.allowed) { + send(res, 429, quoteLimitHeaders, appraisalRateLimitedBody(decision)); + res.on("finish", () => req.destroy()); + return; + } + } + const taken = await takeAppraisalRequestBody(req, MAX_APPRAISAL_BODY_BYTES); if ("status" in taken) { // Fail closed before JSON parsing and before any payment offer. @@ -191,7 +233,10 @@ export async function buildAppraisalServer( return send(res, 404, {}, { error: "not found" }); } if (result.type === "payment-error") { - return writeInstructions(res, result.response); + return writeInstructions(res, { + ...result.response, + headers: { ...quoteLimitHeaders, ...result.response.headers }, + }); } // result.type === "payment-verified": funds are authorized but not yet