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
14 changes: 13 additions & 1 deletion api/src/middleware/x402.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>;

// 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
Expand Down Expand Up @@ -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<void> => {
const requestStartMs = Date.now();

Expand Down Expand Up @@ -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);

Expand Down
13 changes: 12 additions & 1 deletion api/src/routes/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -431,9 +441,10 @@ router.post("/stripe", async (req: Request, res: Response): Promise<void> => {
? 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)
Expand Down
92 changes: 67 additions & 25 deletions api/src/routes/tools/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<Buffer | null> {
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]*\}/);
Expand Down Expand Up @@ -1436,41 +1497,22 @@ 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<void> => {
router.post("/extract-pdf", ...toolMiddleware("extract-pdf", preSettleExtractPdf), async (req: AuthedRequest, res: Response): Promise<void> => {
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 —
// the base64 path previously had no size check at all, letting a 1,000-page
// 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
Expand Down
13 changes: 12 additions & 1 deletion api/tests/billing-helpers.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
17 changes: 16 additions & 1 deletion 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 @@ -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");
Expand Down
Loading