diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b7a32c30..4113cae5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -34,7 +34,8 @@ jobs: run: npm run build - name: Run integration tests - run: npm run test || true # Don't block deploy on test failures (tests need DB) + working-directory: api + run: npm run test - name: Deploy to Render if: success() @@ -43,6 +44,5 @@ jobs: "https://api.render.com/v1/services/srv-d6iuo3ua2pns73asd8dg/deploys" \ -H "Authorization: Bearer ${{ secrets.RENDER_API_KEY }}" \ -H "Content-Type: application/json" \ - -d '{"clearCache":"do_not_clear"}' \ - && echo "✅ Deploy triggered successfully" \ - || echo "⚠️ Deploy trigger failed (check RENDER_API_KEY secret)" + -d '{"clearCache":"do_not_clear"}' + echo "✅ Deploy triggered successfully" diff --git a/api/package.json b/api/package.json index de294d2d..c005d2e7 100644 --- a/api/package.json +++ b/api/package.json @@ -16,7 +16,7 @@ "db:cleanup": "node dist/cron/dbCleanup.js", "funnel-report": "node dist/scripts/funnelReport.js", "npm:audit": "npm audit --json > /tmp/audit.json && echo \"Audit complete\"", -"test": "TS_NODE_TRANSPILE_ONLY=1 node --loader ts-node/esm tests/wallet-provisioning.test.js && node --loader ts-node/esm tests/ssrf.test.js && node tests/integration.test.js && node tests/pages.test.js && node tests/x402-v1-passthrough.test.mjs && node tests/model-cost.test.mjs && node tests/session-pricing.test.mjs && node tests/prompt-moderation.test.mjs && node tests/critical-regressions.test.mjs && node tests/unsubscribe.test.mjs && node tests/reactivation-render.test.mjs && node tests/outreach-active-devs.test.mjs && node tests/credit-alert-dedup.test.mjs && node tests/verify-activation.test.mjs && node tests/signup-firstcall.test.mjs && node tests/oauth-signup-cta.test.mjs && node tests/x402-sell-copy.test.mjs && node tests/verify-resend.test.mjs && node tests/intent-funnel.test.mjs && node tests/credit-email-buylinks.test.mjs", +"test": "TS_NODE_TRANSPILE_ONLY=1 node --loader ts-node/esm tests/wallet-provisioning.test.js && node --loader ts-node/esm tests/ssrf.test.js && node tests/integration.test.js && node tests/pages.test.js && node tests/x402-v1-passthrough.test.mjs && node tests/model-cost.test.mjs && node tests/session-pricing.test.mjs && node tests/prompt-moderation.test.mjs && node tests/critical-regressions.test.mjs && node tests/unsubscribe.test.mjs && node tests/reactivation-render.test.mjs && node tests/outreach-active-devs.test.mjs && node tests/credit-alert-dedup.test.mjs && node tests/verify-activation.test.mjs && node tests/signup-firstcall.test.mjs && node tests/oauth-signup-cta.test.mjs && node tests/x402-sell-copy.test.mjs && node tests/verify-resend.test.mjs && node tests/intent-funnel.test.mjs && node tests/credit-email-buylinks.test.mjs && node tests/tools-credits-hardening.test.mjs", "test:integration": "node tests/integration.test.js", "test:verify-activation": "node tests/verify-activation.test.mjs", "test:verify-resend": "node tests/verify-resend.test.mjs", diff --git a/api/src/middleware/x402.ts b/api/src/middleware/x402.ts index 67778ad4..7b634d0e 100644 --- a/api/src/middleware/x402.ts +++ b/api/src/middleware/x402.ts @@ -195,6 +195,72 @@ export function isX402AnonymousTool(toolName: string): boolean { return !X402_ACCOUNT_REQUIRED_TOOLS.has(toolName); } +type X402BodyField = { + required?: boolean; + enum?: unknown[]; +}; + +export type X402PreflightError = { + field: string; + message: string; +}; + +const X402_PREFLIGHT_ALIASES: Record> = { + "transform-text": { mode: ["operation"] }, + "workflow-agent": { goal: ["task", "objective"] }, +}; + +const X402_PREFLIGHT_REQUIRED_OVERRIDES: Record = { + // Static discovery metadata predates the route's namespace requirement. + "session-create": ["namespace"], +}; + +function hasUsableBodyValue(body: Record, field: string): boolean { + const value = body[field]; + if (value === undefined || value === null) return false; + if (typeof value === "string") return value.trim().length > 0; + return true; +} + +function hasRequiredField(body: Record, toolName: string, field: string): boolean { + if (hasUsableBodyValue(body, field)) return true; + return (X402_PREFLIGHT_ALIASES[toolName]?.[field] ?? []).some((alias) => hasUsableBodyValue(body, alias)); +} + +/** + * Validate cheap, deterministic request shape before settling an anonymous x402 + * payment. Route handlers still own full validation; this preflight exists only + * to avoid charging pay-per-call users for requests that are known to be rejected. + */ +export function preflightX402ToolInput(toolName: string, body: unknown): X402PreflightError | null { + const fields = (TOOL_OUTPUT_SCHEMAS[toolName] as { input?: { bodyFields?: Record } } | undefined)?.input?.bodyFields ?? {}; + const requestBody = body && typeof body === "object" && !Array.isArray(body) + ? body as Record + : {}; + + for (const field of X402_PREFLIGHT_REQUIRED_OVERRIDES[toolName] ?? []) { + if (!hasRequiredField(requestBody, toolName, field)) { + return { field, message: `${field} is required before x402 payment can be settled` }; + } + } + + for (const [field, schema] of Object.entries(fields)) { + if (schema.required && !hasRequiredField(requestBody, toolName, field)) { + return { field, message: `${field} is required before x402 payment can be settled` }; + } + + const value = requestBody[field]; + if (value !== undefined && value !== null && Array.isArray(schema.enum) && schema.enum.length > 0) { + const allowed = schema.enum.map(String); + if (!allowed.includes(String(value))) { + return { field, message: `${field} must be one of: ${allowed.join(", ")}` }; + } + } + } + + return null; +} + /** * 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 @@ -1307,6 +1373,17 @@ export function x402Middleware(toolName: string) { return; } + const preflightError = preflightX402ToolInput(toolName, req.body); + if (preflightError) { + res.status(400).json({ + ok: false, + error: "invalid_request", + message: preflightError.message, + field: preflightError.field, + }); + return; + } + // Nonce-based replay protection. // A missing nonce never hard-rejects (standard @x402/fetch "exact" // payments carry the nonce at payload.authorization.nonce, which we diff --git a/api/tests/tools-credits-hardening.test.mjs b/api/tests/tools-credits-hardening.test.mjs index 2f9235ea..ed5aa649 100644 --- a/api/tests/tools-credits-hardening.test.mjs +++ b/api/tests/tools-credits-hardening.test.mjs @@ -4,6 +4,7 @@ * Covers the confirmed-live fixes on this branch: * A (#22) NFT tokenId path-injection validator (decimal-only, bounded). * B (#19) platform side-effect tools require an account even when x402-paid. + * B.2 x402 preflights known-bad tool inputs before verify/settle. * C.2 (#11) x402 in-memory nonce release on failed verify/settle. * C.3 (#11) AI Oracle BYOK: bad user key never falls through to a free * platform-key response. @@ -104,6 +105,44 @@ async function main() { assert.ok(guardIdx < settleIdx, "guard must precede settle"); }); + // ── B.2: anonymous x402 must not settle requests handlers will reject ───── + console.log("B.2 — x402 preflight before settlement:"); + await test("missing required input is rejected before settlement", () => { + const err = x402.preflightX402ToolInput("generate-hash", {}); + assert.deepStrictEqual(err, { + field: "text", + message: "text is required before x402 payment can be settled", + }); + }); + await test("enum-only invalid input is rejected before settlement", () => { + const err = x402.preflightX402ToolInput("generate-hash", { text: "abc", algorithm: "sha999" }); + assert.deepStrictEqual(err, { + field: "algorithm", + message: "algorithm must be one of: sha256, sha512, md5, sha1", + }); + }); + await test("valid aliases and schema-conformant inputs pass preflight", () => { + assert.strictEqual(x402.preflightX402ToolInput("generate-hash", { text: "abc", algorithm: "sha256" }), null); + assert.strictEqual(x402.preflightX402ToolInput("transform-text", { text: "abc", operation: "upper" }), null); + assert.strictEqual(x402.preflightX402ToolInput("workflow-agent", { task: "do it" }), null); + }); + await test("session-create namespace override catches schema drift", () => { + assert.strictEqual(x402.preflightX402ToolInput("session-create", { namespace: "demo" }), null); + const err = x402.preflightX402ToolInput("session-create", {}); + assert.strictEqual(err?.field, "namespace"); + }); + await test("preflight runs BEFORE nonce reservation, verify, and settle", () => { + const mwIdx = x402Src.indexOf("export function x402Middleware"); + const preflightIdx = x402Src.indexOf("const preflightError = preflightX402ToolInput", mwIdx); + const nonceIdx = x402Src.indexOf("const nonce = extractNonce", mwIdx); + const verifyIdx = x402Src.indexOf("const verifyResult = await verifyPayment", mwIdx); + const settleIdx = x402Src.indexOf("const settleResult = await settlePayment", mwIdx); + assert.ok(preflightIdx > -1, "preflight call missing"); + assert.ok(preflightIdx < nonceIdx, "preflight must happen before nonce reservation"); + assert.ok(preflightIdx < verifyIdx, "preflight must happen before verify"); + assert.ok(preflightIdx < settleIdx, "preflight must happen before settle"); + }); + // ── C.2 (#11): x402 in-memory nonce release on failure ───────────────────── console.log("C.2 — x402 in-memory nonce release:"); const { checkAndStoreNonce, releaseStoredNonce } = x402; @@ -122,7 +161,7 @@ async function main() { assert.ok(x402Src.includes("memNonceCache.delete(nonce)"), "must clear in-memory store"); assert.ok(!/if \(nonce && redis\) await redis\.del/.test(x402Src), "redis-only cleanup must be gone"); const count = (x402Src.match(/if \(nonce\) await releaseStoredNonce\(nonce\)/g) || []).length; - assert.strictEqual(count, 2, "both verify-fail and settle-fail must release the nonce"); + assert.ok(count >= 3, "no-match, verify-fail, and settle-fail branches must release the nonce"); }); // ── C.3 (#11): AI Oracle BYOK — no free platform fallback ────────────────── @@ -197,8 +236,9 @@ async function main() { await test("GET handler calls buildPaymentRequired and never x402Middleware", () => { const getIdx = toolsSrc.indexOf('router.get("/:toolName"'); assert.ok(getIdx > 0, "GET handler missing"); - const getHandler = toolsSrc.slice(getIdx); - assert.ok(getHandler.includes("buildPaymentRequired(toolName, price)"), "must build 402 directly"); + const endIdx = toolsSrc.indexOf("export default router", getIdx); + const getHandler = toolsSrc.slice(getIdx, endIdx > getIdx ? endIdx : undefined); + assert.ok(getHandler.includes("buildPaymentRequiredV2(toolName, price)"), "must build 402 directly"); assert.ok(!getHandler.includes("x402Middleware(toolName)"), "must not route probe through settlement middleware"); assert.ok(getHandler.includes("!isX402AnonymousTool(toolName)"), "account-required tools stay out of discovery"); }); @@ -222,6 +262,18 @@ async function main() { await test("research-report BYOK discount is scoped to its usable providers", () => assert.ok(toolsSrc.includes('byokAdjustedCost(req, 40, ["x-brave-key", "x-tavily-key", "x-anthropic-key"])'))); + // ── Deploy workflow: tests and Render trigger fail closed ────────────────── + console.log("D — deploy workflow failure visibility:"); + const deployWorkflow = fs.readFileSync(path.join(__dirname, "..", "..", ".github", "workflows", "deploy.yml"), "utf-8"); + await test("deploy workflow runs API tests from api/ and does not swallow failures", () => { + assert.ok(/- name: Run integration tests\n\s+working-directory: api\n\s+run: npm run test\b/.test(deployWorkflow)); + assert.ok(!deployWorkflow.includes("npm run test || true"), "test failures must fail the deploy job"); + }); + await test("Render deploy trigger failure fails the workflow step", () => { + assert.ok(!deployWorkflow.includes("|| echo"), "curl failure must not be converted to success"); + assert.ok(/curl -sf -X POST/.test(deployWorkflow), "Render trigger should stay fail-fast"); + }); + if (failures > 0) { console.error(`\n${failures} test(s) failed`); process.exit(1);