diff --git a/.agents/plans/validate-x402-skill-db-id.plan.md b/.agents/plans/validate-x402-skill-db-id.plan.md new file mode 100644 index 00000000..a922e9bf --- /dev/null +++ b/.agents/plans/validate-x402-skill-db-id.plan.md @@ -0,0 +1,64 @@ +--- +name: validate-x402-skill-db-id +overview: "Reject malformed Base x402 skill UUIDs at the shared request boundary so verify and settle return a client error before PostgreSQL UUID casts or payment work." +todos: + - id: validate-shared-skill-id + content: Validate Base x402 skill ID aliases with the existing UUID boundary helper before routes can load a listing — completed 2026-09-01 + status: completed + - id: add-route-regressions + content: Cover malformed Base x402 skill IDs for verify and settle and prove no database/payment helper executes — targeted test passed 2026-09-01 + status: completed + - id: verify-focused-change + content: Run targeted x402 tests plus formatting, lint, typecheck, full web tests, webpack build, and diff checks — completed 2026-09-01 + status: completed +isProject: false +--- + +# Validate Base x402 Skill Database IDs + +## Goal +Return a client-error response for malformed `skillDbId`/`skill_id`/`skillId` values used by the Base x402 verify and settle flows, instead of allowing an invalid value to reach PostgreSQL's `::uuid` cast and the routes' generic `500` handler. + +## Scope +- In scope: shared Base x402 body parsing, x402 verify/settle regression coverage, and this execution record. +- Out of scope: legacy Solana proof behavior, Base payment-payload semantics, database schema changes, live payment flows, and broader x402 refactoring. + +## Files To Change +- `web/lib/baseX402Api.ts`: distinguish malformed Base x402 skill ID aliases from absent IDs with the existing UUID boundary helper. +- `web/app/api/x402/verify/route.ts`: reject malformed Base x402 skill IDs before selecting legacy or Base payment work. +- `web/app/api/x402/settle/route.ts`: apply the same exclusive malformed-ID rejection before settlement logic. +- `web/__tests__/api/x402-routes.test.ts`: retain the real shared parser while mocking payment/database dependencies; add no-side-effect malformed-ID cases for both routes. +- `.agents/plans/validate-x402-skill-db-id.plan.md`: keep implementation and verification state current. + +## Verified Gap (2026-09-01) +- `getBaseX402SkillIdFromBody` accepted any non-empty string, and both routes passed that string to `loadBaseX402Skill`. +- `loadBaseX402Skill` uses `WHERE id = ${skillDbId}::uuid`; an invalid value can therefore throw before an x402 client-error response is selected. +- The existing merged invalid-body hardening in PR #153 covers malformed JSON and literal `null`, not malformed UUID-shaped Base skill IDs. Open PRs #157–166 do not modify these x402 routes or helper. + +## Implementation Steps +1. Apply `isUuidLike` at the shared Base x402 skill-ID extraction boundary after trimming an accepted alias. +2. Change the x402 route test mock to preserve the actual parser while substituting only I/O/payment helpers. +3. Add table-driven malformed-ID assertions for `POST /api/x402/verify` and `POST /api/x402/settle`, using a valid object payment payload to select the Base branch. Assert `400` and no listing load, payment verification, entitlement, settlement, or relay helper call. + +## Verification +- `npm test --workspace @agentvouch/web -- __tests__/api/x402-routes.test.ts --maxWorkers=1 --no-fileParallelism` +- `npm run format:check` +- `npm run lint:web` +- `npm run typecheck` +- `npm test --workspace @agentvouch/web -- --maxWorkers=1 --no-fileParallelism` +- `npm exec --workspace @agentvouch/web -- next build --webpack` +- `git diff --check` + +## Execution Notes (2026-09-01) +- Implemented shared raw-ID extraction plus UUID validation so malformed nonempty aliases are distinguishable from absent IDs. Both Base x402 routes now return `400 { error: "Invalid skillDbId" }` before legacy-proof selection, listing lookup, payment verification, entitlement lookup, or settlement work. +- The regression keeps the real shared parser and covers both routes with an invalid ID and object Base payload; all listed payment/database mocks remain uncalled. +- Verification passed: targeted x402 routes (6 tests), Prettier, web ESLint, web typecheck, full web Vitest suite (128 files / 929 tests), webpack production build, and `git diff --check`. The build retained the repository's existing `ox`/`viem` dynamic-import warning and expected static-generation `DATABASE_URL` fallbacks; no live database or payment flow ran. + +## Rollout +Merge as a normal API hardening PR. The change narrows only malformed Base x402 input before any database or payment helper work; no feature flag, deployment, or migration is required. + +## Rollback +Revert the focused commit to restore the prior parser behavior. No persistent data or external payment state is changed by rejected requests. + +## Blockers +None identified. A live Base x402 transaction is intentionally out of scope because this change rejects malformed request data before payment verification or settlement. diff --git a/web/__tests__/api/x402-routes.test.ts b/web/__tests__/api/x402-routes.test.ts index 4f93f6a7..f69b97b7 100644 --- a/web/__tests__/api/x402-routes.test.ts +++ b/web/__tests__/api/x402-routes.test.ts @@ -24,13 +24,8 @@ vi.mock("@/lib/baseX402", () => ({ verifyBaseX402PaymentPayload: mocks.verifyBaseX402PaymentPayload, relayAndRecordBaseX402Purchase: mocks.relayAndRecordBaseX402Purchase, })); -vi.mock("@/lib/baseX402Api", () => ({ - getBaseX402SkillIdFromBody: (body: Record) => - typeof body.skillDbId === "string" ? body.skillDbId : null, - getBaseX402PayloadFromBody: (body: Record) => - body.paymentPayload && typeof body.paymentPayload === "object" - ? body.paymentPayload - : null, +vi.mock("@/lib/baseX402Api", async (importOriginal) => ({ + ...(await importOriginal()), loadBaseX402Skill: mocks.loadBaseX402Skill, })); vi.mock("@/lib/usdcPurchases", () => ({ @@ -87,4 +82,30 @@ describe.each([ expect(mocks.failX402SettlementAttempt).not.toHaveBeenCalled(); } ); + + it("rejects an invalid Base skillDbId before listing or payment work", async () => { + const response = await handler( + request( + JSON.stringify({ + skillDbId: "not-a-uuid", + paymentPayload: {}, + }) + ) + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + error: "Invalid skillDbId", + }); + expect(mocks.authenticateRequest).toHaveBeenCalledOnce(); + expect(mocks.verifyPaymentProof).not.toHaveBeenCalled(); + expect(mocks.verifyBaseX402PaymentPayload).not.toHaveBeenCalled(); + expect(mocks.loadBaseX402Skill).not.toHaveBeenCalled(); + expect(mocks.relayAndRecordBaseX402Purchase).not.toHaveBeenCalled(); + expect(mocks.hasChainUsdcPurchaseEntitlement).not.toHaveBeenCalled(); + expect(mocks.getX402SettlementEntitlement).not.toHaveBeenCalled(); + expect(mocks.claimX402SettlementAttempt).not.toHaveBeenCalled(); + expect(mocks.completeX402SettlementAttempt).not.toHaveBeenCalled(); + expect(mocks.failX402SettlementAttempt).not.toHaveBeenCalled(); + }); }); diff --git a/web/app/api/x402/settle/route.ts b/web/app/api/x402/settle/route.ts index 7dda6af8..349f5e67 100644 --- a/web/app/api/x402/settle/route.ts +++ b/web/app/api/x402/settle/route.ts @@ -10,6 +10,7 @@ import { import { getBaseX402PayloadFromBody, getBaseX402SkillIdFromBody, + hasInvalidBaseX402SkillId, loadBaseX402Skill, } from "@/lib/baseX402Api"; import { @@ -34,6 +35,9 @@ export async function POST(request: NextRequest) { string, unknown >; + if (hasInvalidBaseX402SkillId(body)) { + return NextResponse.json({ error: "Invalid skillDbId" }, { status: 400 }); + } const baseSkillId = getBaseX402SkillIdFromBody(body); const basePayload = getBaseX402PayloadFromBody(body); if (baseSkillId || basePayload) { diff --git a/web/app/api/x402/verify/route.ts b/web/app/api/x402/verify/route.ts index 089704ed..7abbe084 100644 --- a/web/app/api/x402/verify/route.ts +++ b/web/app/api/x402/verify/route.ts @@ -7,6 +7,7 @@ import { verifyBaseX402PaymentPayload } from "@/lib/baseX402"; import { getBaseX402PayloadFromBody, getBaseX402SkillIdFromBody, + hasInvalidBaseX402SkillId, loadBaseX402Skill, } from "@/lib/baseX402Api"; import { hasChainUsdcPurchaseEntitlement } from "@/lib/usdcPurchases"; @@ -25,6 +26,9 @@ export async function POST(request: NextRequest) { string, unknown >; + if (hasInvalidBaseX402SkillId(body)) { + return NextResponse.json({ error: "Invalid skillDbId" }, { status: 400 }); + } const baseSkillId = getBaseX402SkillIdFromBody(body); const basePayload = getBaseX402PayloadFromBody(body); if (baseSkillId || basePayload) { diff --git a/web/lib/baseX402Api.ts b/web/lib/baseX402Api.ts index 9aaacefb..5abae957 100644 --- a/web/lib/baseX402Api.ts +++ b/web/lib/baseX402Api.ts @@ -6,6 +6,7 @@ import { } from "@/lib/x402"; import type { BaseX402Skill } from "@/lib/baseX402"; import { BASE_SEPOLIA_CHAIN_CONTEXT } from "@/lib/chains"; +import { isUuidLike } from "@/lib/skillUrls"; export type LoadedBaseX402Skill = BaseX402Skill & { price_usdc_micros: string; @@ -26,8 +27,20 @@ export function getBaseX402PayloadFromBody( export function getBaseX402SkillIdFromBody( body: Record ): string | null { + const skillDbId = getBaseX402SkillIdValue(body); + return skillDbId && isUuidLike(skillDbId) ? skillDbId : null; +} + +export function hasInvalidBaseX402SkillId( + body: Record +): boolean { + const skillDbId = getBaseX402SkillIdValue(body); + return Boolean(skillDbId && !isUuidLike(skillDbId)); +} + +function getBaseX402SkillIdValue(body: Record): string | null { const value = body.skillDbId ?? body.skill_id ?? body.skillId; - return typeof value === "string" && value.trim() ? value.trim() : null; + return typeof value === "string" ? value.trim() || null : null; } export async function loadBaseX402Skill(