From 8a52347245d1eafc56b6c1bee57b6e47be897461 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 30 Jul 2026 05:09:42 +0000 Subject: [PATCH 1/2] fix(api): gate x402 video quota before settlement Co-authored-by: Deesmo --- api/src/middleware/x402.ts | 19 +++++++++++++++++++ api/src/routes/tools/index.ts | 5 +++-- api/tests/hardening-caps-audit.test.mjs | 12 ++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/api/src/middleware/x402.ts b/api/src/middleware/x402.ts index 67778ad4..70be90fa 100644 --- a/api/src/middleware/x402.ts +++ b/api/src/middleware/x402.ts @@ -27,6 +27,7 @@ import { DISCOVERY_LINKS } from "../utils/discoveryLinks.js"; import { toV1Requirements, asV1Payload, claimsV1, toV2Payload, toV2Requirements } from "../lib/x402V1.js"; import { toV2PaymentRequired, toV2FacilitatorArgs, toCaip2, networksEqual, paymentPayloadVersion } from "../lib/x402V2.js"; import { getToolSellCopy, railDescription, registerToolSellCopy } from "../lib/toolSellCopy.js"; +import { VIDEO_HOURLY_CAP, videoHourlyGate, releaseVideoHourlySlot } from "../lib/toolLimits.js"; // Per-tool sell copy: load DB Tool.description rows into the sell-copy registry // at startup (Play #6). Sanitization + length-capping happens INSIDE @@ -1390,6 +1391,23 @@ export function x402Middleware(toolName: string) { return; } + let reservedVideoHourlyIdentity: string | null = null; + if (toolName === "video-generate") { + const payer = verifyResult.payer ?? extractPayerAddress(paymentHeader); + const identity = `x402:${payer?.trim().toLowerCase() ?? `ip:${req.ip ?? "unknown"}`}`; + if (!videoHourlyGate(identity)) { + if (nonce) await releaseStoredNonce(nonce); + res.status(429).json({ + ok: false, + error: "video_rate_limited", + message: `Video generation is limited to ${VIDEO_HOURLY_CAP} requests per hour per account — a cost-abuse guard on the underlying Runway generation spend. Try again next hour.`, + }); + return; + } + reservedVideoHourlyIdentity = identity; + (req as Request & { x402VideoHourlyIdentity?: string }).x402VideoHourlyIdentity = identity; + } + // Settle payment using spec-compliant format const settleResult = await settlePayment(paymentHeader, toolName, paymentRequirements); @@ -1401,6 +1419,7 @@ export function x402Middleware(toolName: string) { if (!settled) { // Free the nonce so the agent can retry the payment if (nonce) await releaseStoredNonce(nonce); + if (reservedVideoHourlyIdentity) releaseVideoHourlySlot(reservedVideoHourlyIdentity); try { await prisma.x402Payment.create({ data: { diff --git a/api/src/routes/tools/index.ts b/api/src/routes/tools/index.ts index 250d667a..336a6c95 100644 --- a/api/src/routes/tools/index.ts +++ b/api/src/routes/tools/index.ts @@ -3171,9 +3171,10 @@ router.post("/video-generate", ...toolMiddleware("video-generate"), async (req: // to the caller IP when payer metadata is unresolved, so unrelated callers // never share one bucket). Follows the EMAIL_RECIPIENT_DAILY_CAP in-memory // pattern (PR #76); env-tunable via VIDEO_HOURLY_CAP (default 5/hour). - const videoIdentity = req.agent?.id + const preReservedVideoIdentity = (req as AuthedRequest & { x402VideoHourlyIdentity?: string }).x402VideoHourlyIdentity; + const videoIdentity = preReservedVideoIdentity ?? req.agent?.id ?? `x402:${(req as AuthedRequest & { x402Payer?: string }).x402Payer?.trim().toLowerCase() ?? `ip:${req.ip ?? "unknown"}`}`; - if (!videoHourlyGate(videoIdentity)) { + if (!preReservedVideoIdentity && !videoHourlyGate(videoIdentity)) { res.status(429).json({ ok: false, error: "video_rate_limited", diff --git a/api/tests/hardening-caps-audit.test.mjs b/api/tests/hardening-caps-audit.test.mjs index 98ddc831..44625b42 100644 --- a/api/tests/hardening-caps-audit.test.mjs +++ b/api/tests/hardening-caps-audit.test.mjs @@ -60,6 +60,7 @@ function compressedStylePdf(count) { async function main() { const toolsSrc = fs.readFileSync(src("routes", "tools", "index.ts"), "utf-8"); + const x402Src = fs.readFileSync(src("middleware", "x402.ts"), "utf-8"); const agentSrc = fs.readFileSync(src("routes", "agent.ts"), "utf-8"); const schemaSrc = fs.readFileSync(root("prisma", "schema.prisma"), "utf-8"); const openapiSrc = fs.readFileSync(root("public", "openapi.json"), "utf-8"); @@ -104,6 +105,17 @@ async function main() { assert.ok(/x402:\$\{\(req as AuthedRequest & \{ x402Payer\?: string \}\)\.x402Payer/.test(toolsSrc), "x402 identity key missing — x402-paid generations must also be capped"); }); + await test("source: x402 video cap rejects before settlement and is not double-counted in the route", () => { + const verifyIdx = x402Src.indexOf("const verifyResult = await verifyPayment"); + const x402GateIdx = x402Src.indexOf('if (toolName === "video-generate")'); + const settleIdx = x402Src.indexOf("const settleResult = await settlePayment"); + assert.ok(verifyIdx !== -1 && x402GateIdx > verifyIdx, "x402 video cap must run after payment verification"); + assert.ok(settleIdx !== -1 && x402GateIdx < settleIdx, "x402 video cap must run before settlement"); + assert.ok(x402Src.includes('"video_rate_limited"'), "x402 middleware must be able to return the rate-limit error before settle"); + assert.ok(x402Src.includes("releaseVideoHourlySlot(reservedVideoHourlyIdentity)"), "failed settlement must release the reserved video slot"); + assert.ok(toolsSrc.includes("x402VideoHourlyIdentity"), "route must consume the x402 pre-settlement reservation"); + assert.ok(toolsSrc.includes("!preReservedVideoIdentity && !videoHourlyGate(videoIdentity)"), "route must not count the x402 reservation twice"); + }); // ── 2. web-scrape Firecrawl BYOK/x402 gate ────────────────────────────────── console.log("2 — web-scrape Firecrawl fallback gate:"); From 8d2690528c036357c8a50f0d3a4fc831e5eb0f72 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 30 Jul 2026 05:11:49 +0000 Subject: [PATCH 2/2] fix(api): release reserved video quota on preflight rejects Co-authored-by: Deesmo --- api/src/routes/tools/index.ts | 14 +++++++++----- api/tests/hardening-caps-audit.test.mjs | 5 +++++ 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/api/src/routes/tools/index.ts b/api/src/routes/tools/index.ts index 336a6c95..859c4aaa 100644 --- a/api/src/routes/tools/index.ts +++ b/api/src/routes/tools/index.ts @@ -3140,10 +3140,14 @@ router.post("/session-message", ...toolMiddleware("session-message"), async (req // ─── 54. VIDEO-GENERATE (Runway) ────────────────────────────────────────────── router.post("/video-generate", ...toolMiddleware("video-generate"), async (req: AuthedRequest, res: Response): Promise => { const { prompt, duration = 5, aspect_ratio = "16:9" } = req.body as { prompt?: string; duration?: number; aspect_ratio?: string }; - if (!prompt) { res.status(400).json({ ok: false, error: "invalid_request", message: "prompt is required", request_id: reqId() }); return; } - { const _mod = moderateGenerationPrompt(prompt); if (!_mod.allowed) { console.warn(`[moderation] blocked category=${_mod.category} tool=video-generate`); res.status(400).json({ ok: false, error: "content_policy", category: _mod.category, message: _mod.reason, request_id: reqId() }); return; } } + const preReservedVideoIdentity = (req as AuthedRequest & { x402VideoHourlyIdentity?: string }).x402VideoHourlyIdentity; + const releasePreReservedVideoSlot = (): void => { + if (preReservedVideoIdentity) releaseVideoHourlySlot(preReservedVideoIdentity); + }; + if (!prompt) { releasePreReservedVideoSlot(); res.status(400).json({ ok: false, error: "invalid_request", message: "prompt is required", request_id: reqId() }); return; } + { const _mod = moderateGenerationPrompt(prompt); if (!_mod.allowed) { releasePreReservedVideoSlot(); console.warn(`[moderation] blocked category=${_mod.category} tool=video-generate`); res.status(400).json({ ok: false, error: "content_policy", category: _mod.category, message: _mod.reason, request_id: reqId() }); return; } } const validDurations = [5, 10]; - if (!validDurations.includes(duration)) { res.status(400).json({ ok: false, error: "invalid_request", message: "duration must be 5 or 10", request_id: reqId() }); return; } + if (!validDurations.includes(duration)) { releasePreReservedVideoSlot(); res.status(400).json({ ok: false, error: "invalid_request", message: "duration must be 5 or 10", request_id: reqId() }); return; } // Scaled by duration at 140 credits/second, 700 minimum (5s = 700, 10s = 1400). // Runway gen4.5 ≈ $0.80–1.00 for 10s; at the worst-case $0.00114/credit bulk rate // 1400 credits = $1.60, keeping a safe margin over the top-tier COGS (audit 2026-07-27). @@ -3160,18 +3164,18 @@ router.post("/video-generate", ...toolMiddleware("video-generate"), async (req: const resolvedRatio = ratioAliases[aspect_ratio] ?? aspect_ratio; const validRatios = ["1280:720", "720:1280"]; if (!validRatios.includes(resolvedRatio)) { + releasePreReservedVideoSlot(); res.status(400).json({ ok: false, error: "invalid_request", message: "aspect_ratio must be one of: 16:9, 9:16, 1280:720, 720:1280", request_id: reqId() }); return; } const runwayKey = process.env.RUNWAY_API_KEY; - if (!runwayKey) { res.status(503).json({ ok: false, error: "not_configured", message: "RUNWAY_API_KEY not configured", request_id: reqId() }); return; } + if (!runwayKey) { releasePreReservedVideoSlot(); res.status(503).json({ ok: false, error: "not_configured", message: "RUNWAY_API_KEY not configured", request_id: reqId() }); return; } // Hourly per-identity cap (audit 2026-07-27): Runway bills real money per // generation, so bound the burst blast radius for ALL payment rails — agent // id for credit callers, settled payer wallet for x402 callers (falling back // to the caller IP when payer metadata is unresolved, so unrelated callers // never share one bucket). Follows the EMAIL_RECIPIENT_DAILY_CAP in-memory // pattern (PR #76); env-tunable via VIDEO_HOURLY_CAP (default 5/hour). - const preReservedVideoIdentity = (req as AuthedRequest & { x402VideoHourlyIdentity?: string }).x402VideoHourlyIdentity; const videoIdentity = preReservedVideoIdentity ?? req.agent?.id ?? `x402:${(req as AuthedRequest & { x402Payer?: string }).x402Payer?.trim().toLowerCase() ?? `ip:${req.ip ?? "unknown"}`}`; if (!preReservedVideoIdentity && !videoHourlyGate(videoIdentity)) { diff --git a/api/tests/hardening-caps-audit.test.mjs b/api/tests/hardening-caps-audit.test.mjs index 44625b42..3d55ce8e 100644 --- a/api/tests/hardening-caps-audit.test.mjs +++ b/api/tests/hardening-caps-audit.test.mjs @@ -115,6 +115,11 @@ async function main() { assert.ok(x402Src.includes("releaseVideoHourlySlot(reservedVideoHourlyIdentity)"), "failed settlement must release the reserved video slot"); assert.ok(toolsSrc.includes("x402VideoHourlyIdentity"), "route must consume the x402 pre-settlement reservation"); assert.ok(toolsSrc.includes("!preReservedVideoIdentity && !videoHourlyGate(videoIdentity)"), "route must not count the x402 reservation twice"); + assert.ok(toolsSrc.includes("releasePreReservedVideoSlot"), "route preflight rejects must release x402 pre-settlement reservations"); + const releaseHelperIdx = toolsSrc.indexOf("const releasePreReservedVideoSlot"); + const handlerGateIdx = toolsSrc.indexOf("videoHourlyGate(videoIdentity)"); + assert.ok(releaseHelperIdx !== -1 && handlerGateIdx !== -1 && releaseHelperIdx < handlerGateIdx, + "x402 reservation release helper must be available before route preflight rejects"); }); // ── 2. web-scrape Firecrawl BYOK/x402 gate ──────────────────────────────────