diff --git a/api/src/middleware/x402.ts b/api/src/middleware/x402.ts index 67778ad4..08f36e70 100644 --- a/api/src/middleware/x402.ts +++ b/api/src/middleware/x402.ts @@ -195,6 +195,13 @@ export function isX402AnonymousTool(toolName: string): boolean { return !X402_ACCOUNT_REQUIRED_TOOLS.has(toolName); } +const X402_ANTHROPIC_SYNTHESIS_TOOLS = new Set(["research-report", "fact-check"]); + +function hasAnthropicSynthesisCredential(req: Request): boolean { + const byok = req.headers["x-anthropic-key"]; + return (typeof byok === "string" && byok.trim() !== "") || !!process.env.ANTHROPIC_API_KEY; +} + /** * INTERNAL v1-shaped payment-requirements builder. This remains the single source of * truth for wallets/chains/prices/CDP filtering and for the v1→v2 facilitator @@ -1251,6 +1258,15 @@ export function x402Middleware(toolName: string) { const apiKey = req.headers["x-api-key"] as string | undefined; const hasApiCredential = !!(authHeader?.startsWith("Bearer ") || apiKey); + if ((paymentHeader || !hasApiCredential) && X402_ANTHROPIC_SYNTHESIS_TOOLS.has(toolName) && !hasAnthropicSynthesisCredential(req)) { + res.status(503).json({ + ok: false, + error: "no_provider", + message: "Anthropic key not configured. Pass x-anthropic-key header for BYOK.", + }); + return; + } + // Platform side-effect tools (email through Arch-owned Resend, etc.) must // NOT be reachable via anonymous x402 — require API-key authentication so // the send is attributable to a real account and subject to its limits. diff --git a/api/src/routes/billing.ts b/api/src/routes/billing.ts index 0d8fb48a..db5d99c1 100644 --- a/api/src/routes/billing.ts +++ b/api/src/routes/billing.ts @@ -63,6 +63,10 @@ const LEGACY_PACK_ALIASES = new Map([ ["large pack", "business"], ]); +function normalizeBillingKey(value: unknown): string { + return typeof value === "string" ? value.toLowerCase().trim() : ""; +} + // ─── Monthly subscription plans ──────────────────────────────────────────── const SUBSCRIPTION_PLANS = [ { @@ -171,8 +175,8 @@ router.post("/checkout", requireAuthOrSession, async (req: AuthedRequest, res: R // Accept both `pack` and `plan` — agents mix the two up, and a failed // checkout is a lost sale. If the value names a subscription instead, // answer with the exact corrective call. - const { pack, plan } = req.body as { pack?: string; plan?: string }; - const rawKey = (pack ?? plan ?? "").toLowerCase().trim(); + const { pack, plan } = req.body as { pack?: unknown; plan?: unknown }; + const rawKey = normalizeBillingKey(pack ?? plan); const packKey = LEGACY_PACK_ALIASES.get(rawKey) ?? rawKey; const packConfig = packKey ? CREDIT_PACKS.find(p => p.id === packKey || p.label.toLowerCase().startsWith(packKey)) @@ -226,8 +230,8 @@ router.post("/subscribe", requireAuthOrSession, async (req: AuthedRequest, res: // subscription (all three pack ids collide with -monthly plan tiers). Exact // plan ids always win regardless of key; bare-name expansion is a // subscription-intent convenience reserved for the `plan` key. - const { plan, pack } = req.body as { plan?: string; pack?: string }; - const planKey = (plan ?? pack ?? "").toLowerCase().trim(); + const { plan, pack } = req.body as { plan?: unknown; pack?: unknown }; + const planKey = normalizeBillingKey(plan ?? pack); let planConfig = SUBSCRIPTION_PLANS.find(p => p.id === planKey); if (!planConfig) { const packMatch = CREDIT_PACKS.find(p => p.id === planKey); diff --git a/api/src/routes/oauth.ts b/api/src/routes/oauth.ts index 879869ae..a04e9733 100644 --- a/api/src/routes/oauth.ts +++ b/api/src/routes/oauth.ts @@ -339,10 +339,26 @@ router.post("/register", async (req: Request, res: Response): Promise => { }; // Validate required fields - if (!client_name || !redirect_uris || !Array.isArray(redirect_uris) || redirect_uris.length === 0) { + if (typeof client_name !== "string" || !client_name.trim() || !redirect_uris || !Array.isArray(redirect_uris) || redirect_uris.length === 0) { res.status(400).json({ error: "invalid_client_metadata", error_description: "client_name and redirect_uris are required" }); return; } + if (!redirect_uris.every((uri) => typeof uri === "string")) { + res.status(400).json({ error: "invalid_client_metadata", error_description: "redirect_uris must be an array of strings" }); + return; + } + if (grant_types !== undefined && (!Array.isArray(grant_types) || !grant_types.every((gt) => typeof gt === "string"))) { + res.status(400).json({ error: "invalid_client_metadata", error_description: "grant_types must be an array of strings" }); + return; + } + if (response_types !== undefined && (!Array.isArray(response_types) || !response_types.every((rt) => typeof rt === "string"))) { + res.status(400).json({ error: "invalid_client_metadata", error_description: "response_types must be an array of strings" }); + return; + } + if (token_endpoint_auth_method !== undefined && typeof token_endpoint_auth_method !== "string") { + res.status(400).json({ error: "invalid_client_metadata", error_description: "token_endpoint_auth_method must be a string" }); + return; + } // Validate redirect URIs — must be https or localhost for (const uri of redirect_uris) { diff --git a/api/src/routes/tools/index.ts b/api/src/routes/tools/index.ts index 250d667a..2b9db689 100644 --- a/api/src/routes/tools/index.ts +++ b/api/src/routes/tools/index.ts @@ -2732,7 +2732,6 @@ router.post("/research-report", ...toolMiddleware("research-report"), async (req // Report exactly what was deducted (0 for x402-paid, BYOK-discounted otherwise) — // the flat 15 this used to advertise predates the 2026-07-27 pricing audit (40 base). const researchReportCost = paid ? 0 : byokAdjustedCost(req, 40, ["x-brave-key", "x-tavily-key", "x-anthropic-key"]); - if (!paid) { const ok = await deductCredits(req, res, "research-report", researchReportCost); if (!ok) return; } const query = String(req.body.query ?? req.body.topic ?? req.query.query ?? req.query.topic ?? "").trim(); const depth = String(req.body.depth ?? req.query.depth ?? "standard").toLowerCase(); if (!query) return void res.status(400).json({ ok: false, error: "missing_param", message: "query is required" }); @@ -2748,6 +2747,11 @@ router.post("/research-report", ...toolMiddleware("research-report"), async (req const numResults = depth === "deep" ? 10 : 5; const rrHasByok = !!(byokBraveKeyRR || byokTavilyKeyRR || byokAnthropicKeyRR); + if (!anthropicKey) { + return void res.status(503).json({ ok: false, error: "no_provider", message: "Anthropic key not configured. Pass x-anthropic-key header for BYOK.", request_id: reqId() }); + } + if (!paid) { const ok = await deductCredits(req, res, "research-report", researchReportCost); if (!ok) return; } + // Step 1: Gather search results let searchResults: Array<{ title: string; url: string; description: string }> = []; let rrSearchProvider = ""; @@ -2785,10 +2789,6 @@ router.post("/research-report", ...toolMiddleware("research-report"), async (req } // Step 2: Synthesize with Claude - if (!anthropicKey) { - return void res.json({ ok: true, query, sources: searchResults, report: null, message: "Search results only — Anthropic key not configured. Pass x-anthropic-key header for BYOK.", credits_used: researchReportCost, ...(rrHasByok ? { byok: true, byok_provider: rrSearchProvider || "unknown" } : {}), request_id: reqId() }); - } - const sourcesText = searchResults.map((s, i) => `[${i+1}] ${s.title}\n${s.url}\n${s.description}`).join("\n\n"); const systemPrompt = `You are a research analyst. Write a concise, well-structured research report based on the provided sources. Include: an executive summary, key findings, and a conclusion. Cite sources using [N] notation. Be factual and objective.`; const userPrompt = `Research query: "${query}"\n\nSources:\n${sourcesText}\n\nWrite a ${depth === "deep" ? "comprehensive" : "concise"} research report.`; @@ -2803,6 +2803,9 @@ router.post("/research-report", ...toolMiddleware("research-report"), async (req timeout: 30000 }); const report = ((claude.data as { content?: Array<{ text?: string }> }).content?.[0]?.text ?? "").trim(); + if (!report) { + return void res.status(502).json({ ok: false, error: "synthesis_failed", message: "Anthropic returned an empty report", request_id: reqId() }); + } return void res.json({ ok: true, query, depth, report, sources: searchResults, credits_used: researchReportCost, ...(rrHasByok ? { byok: true, byok_provider: byokAnthropicKeyRR ? "anthropic" : rrSearchProvider } : {}), request_id: reqId() }); } catch (e) { return void res.status(502).json({ ok: false, error: "synthesis_failed", message: safeErr(e), request_id: reqId() }); @@ -2812,7 +2815,6 @@ router.post("/research-report", ...toolMiddleware("research-report"), async (req // ─── 53. FACT-CHECK ─────────────────────────────────────────────────────────── router.post("/fact-check", ...toolMiddleware("fact-check"), async (req: AuthedRequest, res: Response): Promise => { const paid = isX402Paid(req); - if (!paid) { const ok = await deductCredits(req, res, "fact-check", 14); if (!ok) return; } const claim = String(req.body.claim ?? req.query.claim ?? "").trim(); if (!claim) return void res.status(400).json({ ok: false, error: "missing_param", message: "claim is required" }); @@ -2827,6 +2829,11 @@ router.post("/fact-check", ...toolMiddleware("fact-check"), async (req: AuthedRe const fcHasByok = !!(byokBraveKeyFC || byokTavilyKeyFC || byokAnthropicKeyFC); let fcSearchProvider = ""; + if (!anthropicKey) { + return void res.status(503).json({ ok: false, error: "no_provider", message: "Anthropic key not configured. Pass x-anthropic-key header for BYOK.", request_id: reqId() }); + } + if (!paid) { const ok = await deductCredits(req, res, "fact-check", 14); if (!ok) return; } + // Step 1: Search for evidence let evidence: Array<{ title: string; url: string; description: string }> = []; @@ -2858,10 +2865,6 @@ router.post("/fact-check", ...toolMiddleware("fact-check"), async (req: AuthedRe } catch (_) { /* fall through */ } } - if (!anthropicKey) { - return void res.json({ ok: true, claim, verdict: null, confidence: null, evidence, message: "Evidence only — Anthropic key not configured. Pass x-anthropic-key header for BYOK.", credits_used: 14, ...(fcHasByok ? { byok: true, byok_provider: fcSearchProvider || "unknown" } : {}), request_id: reqId() }); - } - // Step 2: Analyze with Claude const evidenceText = evidence.map((e, i) => `[${i+1}] ${e.title}\n${e.url}\n${e.description}`).join("\n\n"); const systemPrompt = `You are a professional fact-checker. Analyze the provided claim and evidence to determine its accuracy. Respond in JSON with exactly these fields: @@ -2882,7 +2885,10 @@ router.post("/fact-check", ...toolMiddleware("fact-check"), async (req: AuthedRe timeout: 20000 }); - const raw = (claude.data as { content?: Array<{ text?: string }> }).content?.[0]?.text ?? "{}"; + const raw = ((claude.data as { content?: Array<{ text?: string }> }).content?.[0]?.text ?? "").trim(); + if (!raw) { + return void res.status(502).json({ ok: false, error: "analysis_failed", message: "Anthropic returned an empty analysis", request_id: reqId() }); + } let analysis: Record = {}; try { // Extract JSON from response (may have markdown wrapping) diff --git a/api/tests/critical-regressions.test.mjs b/api/tests/critical-regressions.test.mjs index 5d4b4609..3f3f6144 100644 --- a/api/tests/critical-regressions.test.mjs +++ b/api/tests/critical-regressions.test.mjs @@ -12,7 +12,10 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const srcRoot = path.join(__dirname, "..", "src"); const agentSrc = fs.readFileSync(path.join(srcRoot, "routes", "agent.ts"), "utf8"); +const billingSrc = fs.readFileSync(path.join(srcRoot, "routes", "billing.ts"), "utf8"); +const oauthSrc = fs.readFileSync(path.join(srcRoot, "routes", "oauth.ts"), "utf8"); const toolsSrc = fs.readFileSync(path.join(srcRoot, "routes", "tools", "index.ts"), "utf8"); +const x402Src = fs.readFileSync(path.join(srcRoot, "middleware", "x402.ts"), "utf8"); const seedSrc = fs.readFileSync(path.join(srcRoot, "seed.ts"), "utf8"); let passed = 0; @@ -43,6 +46,13 @@ function responseCredits(toolName) { return new Set(matches); } +function toolRoute(toolName) { + const start = toolsSrc.indexOf(`router.post("/${toolName}"`); + assert.ok(start >= 0, `missing ${toolName} route`); + const end = toolsSrc.indexOf("router.post(", start + 1); + return toolsSrc.slice(start, end > start ? end : undefined); +} + test("account deletion erases SignupIdentity using the shared normalized email identity", () => { assert.ok(agentSrc.includes("normalizeEmailIdentity"), "agent route imports normalizeEmailIdentity"); assert.match( @@ -81,17 +91,56 @@ test("successful result bodies report the same fixed price the route deducts", ( }); test("research-report reports the deducted cost variable, not the stale flat 15", () => { - const start = toolsSrc.indexOf('router.post("/research-report"'); - assert.ok(start >= 0, "missing research-report route"); - const end = toolsSrc.indexOf("router.post(", start + 1); - const route = toolsSrc.slice(start, end > start ? end : undefined); + const route = toolRoute("research-report"); assert.ok(!/credits_used:\s*15\b/.test(route), "stale flat credits_used: 15 must be gone"); assert.match(route, /deductCredits\(req, res, "research-report", researchReportCost\)/); assert.match(route, /researchReportCost = paid \? 0 : byokAdjustedCost\(req, 40,/); const payloads = [...route.matchAll(/credits_used:\s*([A-Za-z_$][\w$]*)/g)].map((m) => m[1]); - assert.ok(payloads.length >= 2, "research-report has both success payloads"); + assert.ok(payloads.length >= 1, "research-report has a success payload"); assert.ok(payloads.every((v) => v === "researchReportCost"), "every payload reports the deducted cost"); }); +test("OAuth dynamic client registration rejects malformed metadata arrays before iterating or persisting", () => { + assert.match(oauthSrc, /typeof client_name !== "string"/, "client_name must be type-checked"); + assert.match(oauthSrc, /redirect_uris\.every\(\(uri\) => typeof uri === "string"\)/, "redirect_uris entries must be strings"); + assert.match(oauthSrc, /grant_types !== undefined && \(!Array\.isArray\(grant_types\)/, "grant_types must be checked before iteration"); + assert.match(oauthSrc, /response_types !== undefined && \(!Array\.isArray\(response_types\)/, "response_types must be checked before response/persistence"); + assert.match(oauthSrc, /token_endpoint_auth_method !== undefined && typeof token_endpoint_auth_method !== "string"/, "auth method must be a string"); +}); + +test("billing checkout and subscription normalize only string pack or plan values", () => { + assert.match(billingSrc, /function normalizeBillingKey\(value: unknown\): string \{\s*return typeof value === "string" \? value\.toLowerCase\(\)\.trim\(\) : "";\s*\}/); + assert.match(billingSrc, /const \{ pack, plan \} = req\.body as \{ pack\?: unknown; plan\?: unknown \};\s*const rawKey = normalizeBillingKey\(pack \?\? plan\);/); + assert.match(billingSrc, /const \{ plan, pack \} = req\.body as \{ plan\?: unknown; pack\?: unknown \};\s*const planKey = normalizeBillingKey\(plan \?\? pack\);/); +}); + +test("research-report and fact-check fail closed when Anthropic cannot produce the paid artifact", () => { + const researchRoute = toolRoute("research-report"); + const factRoute = toolRoute("fact-check"); + + const researchProviderCheck = researchRoute.indexOf('error: "no_provider"'); + const researchDeduct = researchRoute.indexOf('deductCredits(req, res, "research-report", researchReportCost)'); + assert.ok(researchProviderCheck > 0, "research-report must reject missing Anthropic"); + assert.ok(researchProviderCheck < researchDeduct, "research-report must not deduct credits before rejecting missing Anthropic"); + assert.ok(!researchRoute.includes("report: null"), "research-report must not return ok:true with report:null"); + assert.match(researchRoute, /if \(!report\) \{\s*return void res\.status\(502\)\.json\(\{ ok: false, error: "synthesis_failed"/, "empty reports must fail"); + + const factProviderCheck = factRoute.indexOf('error: "no_provider"'); + const factDeduct = factRoute.indexOf('deductCredits(req, res, "fact-check", 14)'); + assert.ok(factProviderCheck > 0, "fact-check must reject missing Anthropic"); + assert.ok(factProviderCheck < factDeduct, "fact-check must not deduct credits before rejecting missing Anthropic"); + assert.ok(!factRoute.includes("verdict: null"), "fact-check must not return ok:true with verdict:null"); + assert.match(factRoute, /if \(!raw\) \{\s*return void res\.status\(502\)\.json\(\{ ok: false, error: "analysis_failed"/, "empty analysis must fail"); +}); + +test("anonymous x402 analysis tools reject missing Anthropic before returning a payment challenge or settling", () => { + assert.match(x402Src, /const X402_ANTHROPIC_SYNTHESIS_TOOLS = new Set\(\["research-report", "fact-check"\]\);/); + assert.match( + x402Src, + /if \(\(paymentHeader \|\| !hasApiCredential\) && X402_ANTHROPIC_SYNTHESIS_TOOLS\.has\(toolName\) && !hasAnthropicSynthesisCredential\(req\)\)/, + "x402 middleware must fail provider-unavailable analysis calls before settlement", + ); +}); + console.log(`\n${passed} passed, ${failed} failed`); process.exit(failed > 0 ? 1 : 0);