diff --git a/api/src/middleware/x402.ts b/api/src/middleware/x402.ts index 67778ad4..3b7c0d2f 100644 --- a/api/src/middleware/x402.ts +++ b/api/src/middleware/x402.ts @@ -28,6 +28,8 @@ import { toV1Requirements, asV1Payload, claimsV1, toV2Payload, toV2Requirements import { toV2PaymentRequired, toV2FacilitatorArgs, toCaip2, networksEqual, paymentPayloadVersion } from "../lib/x402V2.js"; import { getToolSellCopy, railDescription, registerToolSellCopy } from "../lib/toolSellCopy.js"; +export type X402PreSettleCheck = (req: Request, res: Response) => boolean | Promise; + // Per-tool sell copy: load DB Tool.description rows into the sell-copy registry // at startup (Play #6). Sanitization + length-capping happens INSIDE // registerToolSellCopy at the insert boundary (council mod: nothing DB-sourced @@ -1234,7 +1236,7 @@ async function settlePayment(paymentHeader: string, toolName: string, paymentReq * Checks for X-Payment header; if missing + no valid API key, returns 402. * If X-Payment present, verifies with facilitator and logs payment. */ -export function x402Middleware(toolName: string) { +export function x402Middleware(toolName: string, preSettleCheck?: X402PreSettleCheck) { return async (req: Request, res: Response, next: NextFunction): Promise => { const requestStartMs = Date.now(); @@ -1390,6 +1392,16 @@ export function x402Middleware(toolName: string) { return; } + if (preSettleCheck) { + const okToSettle = await preSettleCheck(req, res); + if (!okToSettle) { + // No settlement occurred, so free the nonce and let the caller retry + // with a corrected request body against the same payment proof. + if (nonce) await releaseStoredNonce(nonce); + return; + } + } + // Settle payment using spec-compliant format const settleResult = await settlePayment(paymentHeader, toolName, paymentRequirements); diff --git a/api/src/routes/billing.ts b/api/src/routes/billing.ts index 0d8fb48a..f22ca92e 100644 --- a/api/src/routes/billing.ts +++ b/api/src/routes/billing.ts @@ -132,6 +132,16 @@ export async function paymentIntentIdFromCheckoutSession(session: { return stripeObjectId(invoice.payment_intent); } +export function agentUpdateForPaidSubscriptionInvoice( + creditsPerMonth: number, + planId: string | undefined +): { credits: { increment: number }; tier?: string } { + const tier = tierFromSubscriptionPlanId(planId ?? ""); + return tier === "free" + ? { credits: { increment: creditsPerMonth } } + : { credits: { increment: creditsPerMonth }, tier }; +} + // GET /v1/billing/plans — returns all plans (one-time + subscription) router.get("/plans", (_req: Request, res: Response): void => { res.json({ @@ -431,9 +441,10 @@ router.post("/stripe", async (req: Request, res: Response): Promise => { ? invoice.payment_intent : invoice.payment_intent?.id ?? null; + const agentUpdate = agentUpdateForPaidSubscriptionInvoice(creditsPerMonth, subscription.metadata?.plan_id); await prisma.$transaction([ prisma.purchase.create({ data: { agentId, stripeId: invoiceId, paymentIntentId: renewalPaymentIntentId, credits: creditsPerMonth, amountCents: invoice.amount_paid ?? 0, status: "completed" } }), - prisma.agent.update({ where: { id: agentId }, data: { credits: { increment: creditsPerMonth } } }), + prisma.agent.update({ where: { id: agentId }, data: agentUpdate }), ]); console.log(`[billing] Renewal: +${creditsPerMonth} credits to agent ${agentId}`); // Fire webhook event (non-blocking) diff --git a/api/src/routes/tools/index.ts b/api/src/routes/tools/index.ts index 250d667a..c2cd9aa0 100644 --- a/api/src/routes/tools/index.ts +++ b/api/src/routes/tools/index.ts @@ -1,6 +1,6 @@ import { Router, Request, Response, NextFunction } from "express"; import { requireAuth, AuthedRequest } from "../../middleware/auth.js"; -import { x402Middleware, X402_PRICES, isX402AnonymousTool, buildPaymentRequiredV2 } from "../../middleware/x402.js"; +import { x402Middleware, X402_PRICES, isX402AnonymousTool, buildPaymentRequiredV2, type X402PreSettleCheck } from "../../middleware/x402.js"; import { deductCredits, reqId, safeErr, waiveCharge } from "../../utils/credits.js"; import { getCached, setCached } from "../../lib/lru.js"; import { config } from "../../config.js"; @@ -129,8 +129,8 @@ function requireExecuteScope(req: AuthedRequest, res: Response, next: NextFuncti next(); } -function toolMiddleware(toolName: string) { - return [x402Middleware(toolName), requireAuth, requireExecuteScope, tierRateLimiter]; +function toolMiddleware(toolName: string, preSettleCheck?: X402PreSettleCheck) { + return [x402Middleware(toolName, preSettleCheck), requireAuth, requireExecuteScope, tierRateLimiter]; } function isX402Paid(req: Request): boolean { @@ -187,6 +187,67 @@ function byokAdjustedCost(req: Request, cost: number, headerNames: readonly stri return hasByokKeys(req, headerNames) ? Math.max(1, Math.ceil(cost * 0.2)) : cost; } +type ExtractPdfRequest = AuthedRequest & { extractPdfBuffer?: Buffer }; + +function extractPdfInput(req: Request): { pdfBase64?: string; pdfUrl?: string } { + const body = req.body as { pdf_base64?: string; pdf_url?: string; url?: string }; + return { + pdfBase64: body.pdf_base64, + pdfUrl: (body.pdf_url ?? body.url) as string | undefined, + }; +} + +async function loadExtractPdfBuffer(req: Request, res: Response): Promise { + const { pdfBase64, pdfUrl } = extractPdfInput(req); + if (!pdfUrl && !pdfBase64) { + res.status(400).json({ ok: false, error: "invalid_request", message: "pdf_url (or url) or pdf_base64 is required", request_id: reqId() }); + return null; + } + + if (pdfUrl && !pdfBase64) { + try { + await validateUrl(pdfUrl); + } catch (err) { + res.status(400).json({ ok: false, error: "invalid_url", message: (err as Error).message, request_id: reqId() }); + return null; + } + const resp = await safeAxiosGet(pdfUrl, { responseType: "arraybuffer", timeout: 20000 }); + return Buffer.from(resp.data as ArrayBuffer); + } + + return Buffer.from(pdfBase64!, "base64"); +} + +function enforceExtractPdfCaps(buffer: Buffer, res: Response): boolean { + const maxMb = Math.round(EXTRACT_PDF_MAX_BYTES / (1024 * 1024)); + if (buffer.length > EXTRACT_PDF_MAX_BYTES) { + res.status(400).json({ ok: false, error: "file_too_large", message: `PDF must be under ${maxMb}MB (applies to both pdf_url and pdf_base64 input)`, request_id: reqId() }); + return false; + } + const estPages = estimatePdfPageCount(buffer); + if (estPages > EXTRACT_PDF_MAX_PAGES) { + res.status(400).json({ ok: false, error: "pdf_too_large", message: `This PDF appears to have ~${estPages} pages — extract-pdf accepts at most ${EXTRACT_PDF_MAX_PAGES} pages per call at its flat price. Split the document and extract it in parts.`, request_id: reqId() }); + return false; + } + return true; +} + +const preSettleExtractPdf: X402PreSettleCheck = async (req, res) => { + if (!getAnthropic()) { + res.status(503).json({ ok: false, error: "service_unavailable", message: "This tool requires an Anthropic API key that has not been configured.", request_id: reqId() }); + return false; + } + try { + const buffer = await loadExtractPdfBuffer(req, res); + if (!buffer || !enforceExtractPdfCaps(buffer, res)) return false; + (req as ExtractPdfRequest).extractPdfBuffer = buffer; + return true; + } catch (e) { + res.status(500).json({ ok: false, error: "pdf_error", message: safeErr(e), request_id: reqId() }); + return false; + } +}; + function extractJsonObject(text: string): string | null { const cleaned = text.replace(/```json|```/g, "").trim(); const match = cleaned.match(/\{[\s\S]*\}/); @@ -1436,16 +1497,13 @@ router.post("/browser-task", ...toolMiddleware("browser-task"), async (req: Auth // ─── 30. EXTRACT-PDF ───────────────────────────────────────────────────────── -router.post("/extract-pdf", ...toolMiddleware("extract-pdf"), async (req: AuthedRequest, res: Response): Promise => { +router.post("/extract-pdf", ...toolMiddleware("extract-pdf", preSettleExtractPdf), async (req: AuthedRequest, res: Response): Promise => { const paid = isX402Paid(req); if (!paid) { const ok = await deductCredits(req, res, "extract-pdf", 6); if (!ok) return; } if (!getAnthropic()) { res.status(503).json({ ok: false, error: "service_unavailable", message: "This tool requires an Anthropic API key that has not been configured.", request_id: reqId() }); return; } - const { pdf_base64 } = req.body as { pdf_base64?: string }; - const pdf_url = (req.body.pdf_url ?? req.body.url) as string | undefined; // accept documented alias `url` - if (!pdf_url && !pdf_base64) { res.status(400).json({ ok: false, error: "invalid_request", message: "pdf_url (or url) or pdf_base64 is required", request_id: reqId() }); return; } try { // Size caps (audit 2026-07-27): the whole document goes to Anthropic as // billed input tokens (~1,500–3,000 per page), so bound BOTH input paths — @@ -1453,24 +1511,8 @@ router.post("/extract-pdf", ...toolMiddleware("extract-pdf"), async (req: Authed // PDF consume unbounded inference at a flat 6-credit price. Env-tunable via // EXTRACT_PDF_MAX_BYTES / EXTRACT_PDF_MAX_PAGES; limits are advertised in // the tool description + openapi.json (advertised=charged includes limits). - let buffer: Buffer; - if (pdf_url && !pdf_base64) { - try { await validateUrl(pdf_url); } catch (err) { res.status(400).json({ ok: false, error: "invalid_url", message: (err as Error).message, request_id: reqId() }); return; } - const resp = await safeAxiosGet(pdf_url, { responseType: "arraybuffer", timeout: 20000 }); - buffer = Buffer.from(resp.data as ArrayBuffer); - } else { - buffer = Buffer.from(pdf_base64!, "base64"); - } - const maxMb = Math.round(EXTRACT_PDF_MAX_BYTES / (1024 * 1024)); - if (buffer.length > EXTRACT_PDF_MAX_BYTES) { - res.status(400).json({ ok: false, error: "file_too_large", message: `PDF must be under ${maxMb}MB (applies to both pdf_url and pdf_base64 input)`, request_id: reqId() }); - return; - } - const estPages = estimatePdfPageCount(buffer); - if (estPages > EXTRACT_PDF_MAX_PAGES) { - res.status(400).json({ ok: false, error: "pdf_too_large", message: `This PDF appears to have ~${estPages} pages — extract-pdf accepts at most ${EXTRACT_PDF_MAX_PAGES} pages per call at its flat price. Split the document and extract it in parts.`, request_id: reqId() }); - return; - } + const buffer = (req as ExtractPdfRequest).extractPdfBuffer ?? await loadExtractPdfBuffer(req, res); + if (!buffer || !enforceExtractPdfCaps(buffer, res)) return; const base64Data = buffer.toString("base64"); try { // Use messages.create with betas header for PDF document type support diff --git a/api/tests/billing-helpers.test.mjs b/api/tests/billing-helpers.test.mjs index 04790a9a..3a0ce5aa 100644 --- a/api/tests/billing-helpers.test.mjs +++ b/api/tests/billing-helpers.test.mjs @@ -7,7 +7,10 @@ process.env.DATABASE_URL ??= "postgresql://stub:stub@127.0.0.1:5432/stub"; process.env.JWT_SECRET ??= "test-secret-for-billing-helper-import"; -const { paymentIntentIdFromCheckoutSession } = await import("../dist/routes/billing.js"); +const { + agentUpdateForPaidSubscriptionInvoice, + paymentIntentIdFromCheckoutSession, +} = await import("../dist/routes/billing.js"); let passed = 0; let failed = 0; @@ -45,5 +48,13 @@ const missing = await paymentIntentIdFromCheckoutSession({}, async () => { }); assert(missing === null, "returns null when neither session nor invoice has a PaymentIntent"); +const paidRenewalUpdate = agentUpdateForPaidSubscriptionInvoice(30000, "pro-monthly"); +assert(paidRenewalUpdate.credits?.increment === 30000, "paid renewal increments subscription credits"); +assert(paidRenewalUpdate.tier === "pro", "paid renewal restores the paid tier after a failed-payment downgrade"); + +const malformedRenewalUpdate = agentUpdateForPaidSubscriptionInvoice(30000, undefined); +assert(malformedRenewalUpdate.credits?.increment === 30000, "malformed renewal metadata still increments allowed credits"); +assert(!("tier" in malformedRenewalUpdate), "malformed renewal metadata does not downgrade the existing tier"); + console.log(`Billing helper tests passed: ${passed}, failed: ${failed}`); process.exit(failed > 0 ? 1 : 0); diff --git a/api/tests/hardening-caps-audit.test.mjs b/api/tests/hardening-caps-audit.test.mjs index 98ddc831..5b1c803c 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"); @@ -159,13 +160,27 @@ async function main() { await test("source: both input paths are capped before the Anthropic call", () => { assert.ok(toolsSrc.includes("buffer.length > EXTRACT_PDF_MAX_BYTES"), "bytes cap missing"); assert.ok(toolsSrc.includes("estPages > EXTRACT_PDF_MAX_PAGES"), "pages cap missing"); - assert.ok(toolsSrc.includes('Buffer.from(pdf_base64!, "base64")'), + assert.ok(toolsSrc.includes('Buffer.from(pdfBase64!, "base64")'), "base64 input must be decoded and size-checked (it previously had no cap)"); assert.ok(toolsSrc.includes('"pdf_too_large"'), "pages-cap error code missing"); const capIdx = toolsSrc.indexOf("EXTRACT_PDF_MAX_BYTES"); const anthropicIdx = toolsSrc.indexOf('"anthropic-beta": "pdfs-2024-09-25"'); assert.ok(capIdx !== -1 && capIdx < anthropicIdx, "caps must run before the model call"); }); + await test("source: x402 extract-pdf caps run after verify but before settlement", () => { + assert.ok( + toolsSrc.includes('router.post("/extract-pdf", ...toolMiddleware("extract-pdf", preSettleExtractPdf)'), + "extract-pdf route must wire the x402 pre-settlement cap check"); + assert.ok(toolsSrc.includes("(req as ExtractPdfRequest).extractPdfBuffer = buffer"), + "pre-settlement PDF fetch must be reused by the handler"); + const verifyIdx = x402Src.indexOf("const verifyResult = await verifyPayment"); + const preSettleIdx = x402Src.indexOf("const okToSettle = await preSettleCheck"); + const settleIdx = x402Src.indexOf("const settleResult = await settlePayment"); + assert.ok(verifyIdx !== -1 && preSettleIdx > verifyIdx, "pre-settle check must run only after payment verification"); + assert.ok(settleIdx !== -1 && preSettleIdx < settleIdx, "pre-settle check must run before payment settlement"); + assert.ok(x402Src.slice(preSettleIdx, settleIdx).includes("releaseStoredNonce(nonce)"), + "rejected pre-settlement requests must not pin the nonce"); + }); await test("advertised = charged: openapi.json + discovery describe the caps", () => { assert.ok(openapiSrc.includes("max 5MB / 50 pages per call"), "openapi.json summary missing the limits"); assert.ok(openapiSrc.includes("same 5MB / 50-page limit applies"), "openapi.json pdf_base64 description missing the limits");