Skip to content
Draft
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
19 changes: 19 additions & 0 deletions api/src/middleware/x402.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);

Expand All @@ -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: {
Expand Down
17 changes: 11 additions & 6 deletions api/src/routes/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> => {
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).
Expand All @@ -3160,20 +3164,21 @@ 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 videoIdentity = req.agent?.id
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",
Expand Down
17 changes: 17 additions & 0 deletions api/tests/hardening-caps-audit.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -104,6 +105,22 @@ 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");
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 ──────────────────────────────────
console.log("2 — web-scrape Firecrawl fallback gate:");
Expand Down
Loading