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
8 changes: 4 additions & 4 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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"
2 changes: 1 addition & 1 deletion api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
77 changes: 77 additions & 0 deletions api/src/middleware/x402.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Record<string, readonly string[]>> = {
"transform-text": { mode: ["operation"] },
"workflow-agent": { goal: ["task", "objective"] },
};

const X402_PREFLIGHT_REQUIRED_OVERRIDES: Record<string, readonly string[]> = {
// Static discovery metadata predates the route's namespace requirement.
"session-create": ["namespace"],
};

function hasUsableBodyValue(body: Record<string, unknown>, 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<string, unknown>, 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<string, X402BodyField> } } | undefined)?.input?.bodyFields ?? {};
const requestBody = body && typeof body === "object" && !Array.isArray(body)
? body as Record<string, unknown>
: {};

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
Expand Down Expand Up @@ -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
Expand Down
58 changes: 55 additions & 3 deletions api/tests/tools-credits-hardening.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand All @@ -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 ──────────────────
Expand Down Expand Up @@ -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");
});
Expand All @@ -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);
Expand Down
Loading