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
16 changes: 16 additions & 0 deletions api/src/middleware/x402.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
12 changes: 8 additions & 4 deletions api/src/routes/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ const LEGACY_PACK_ALIASES = new Map<string, string>([
["large pack", "business"],
]);

function normalizeBillingKey(value: unknown): string {
return typeof value === "string" ? value.toLowerCase().trim() : "";
}

// ─── Monthly subscription plans ────────────────────────────────────────────
const SUBSCRIPTION_PLANS = [
{
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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);
Expand Down
18 changes: 17 additions & 1 deletion api/src/routes/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,10 +339,26 @@ router.post("/register", async (req: Request, res: Response): Promise<void> => {
};

// 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) {
Expand Down
28 changes: 17 additions & 11 deletions api/src/routes/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
Expand All @@ -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 = "";
Expand Down Expand Up @@ -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.`;
Expand All @@ -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() });
Expand All @@ -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<void> => {
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" });

Expand All @@ -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 }> = [];

Expand Down Expand Up @@ -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:
Expand All @@ -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<string, unknown> = {};
try {
// Extract JSON from response (may have markdown wrapping)
Expand Down
59 changes: 54 additions & 5 deletions api/tests/critical-regressions.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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);
Loading