Skip to content
Merged
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
64 changes: 64 additions & 0 deletions .agents/plans/validate-x402-skill-db-id.plan.md
Original file line number Diff line number Diff line change
@@ -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.
35 changes: 28 additions & 7 deletions web/__tests__/api/x402-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,8 @@ vi.mock("@/lib/baseX402", () => ({
verifyBaseX402PaymentPayload: mocks.verifyBaseX402PaymentPayload,
relayAndRecordBaseX402Purchase: mocks.relayAndRecordBaseX402Purchase,
}));
vi.mock("@/lib/baseX402Api", () => ({
getBaseX402SkillIdFromBody: (body: Record<string, unknown>) =>
typeof body.skillDbId === "string" ? body.skillDbId : null,
getBaseX402PayloadFromBody: (body: Record<string, unknown>) =>
body.paymentPayload && typeof body.paymentPayload === "object"
? body.paymentPayload
: null,
vi.mock("@/lib/baseX402Api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/baseX402Api")>()),
loadBaseX402Skill: mocks.loadBaseX402Skill,
}));
vi.mock("@/lib/usdcPurchases", () => ({
Expand Down Expand Up @@ -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();
});
});
4 changes: 4 additions & 0 deletions web/app/api/x402/settle/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import {
getBaseX402PayloadFromBody,
getBaseX402SkillIdFromBody,
hasInvalidBaseX402SkillId,
loadBaseX402Skill,
} from "@/lib/baseX402Api";
import {
Expand All @@ -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) {
Expand Down
4 changes: 4 additions & 0 deletions web/app/api/x402/verify/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { verifyBaseX402PaymentPayload } from "@/lib/baseX402";
import {
getBaseX402PayloadFromBody,
getBaseX402SkillIdFromBody,
hasInvalidBaseX402SkillId,
loadBaseX402Skill,
} from "@/lib/baseX402Api";
import { hasChainUsdcPurchaseEntitlement } from "@/lib/usdcPurchases";
Expand All @@ -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) {
Expand Down
15 changes: 14 additions & 1 deletion web/lib/baseX402Api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -26,8 +27,20 @@ export function getBaseX402PayloadFromBody(
export function getBaseX402SkillIdFromBody(
body: Record<string, unknown>
): string | null {
const skillDbId = getBaseX402SkillIdValue(body);
return skillDbId && isUuidLike(skillDbId) ? skillDbId : null;
}

export function hasInvalidBaseX402SkillId(
body: Record<string, unknown>
): boolean {
const skillDbId = getBaseX402SkillIdValue(body);
return Boolean(skillDbId && !isUuidLike(skillDbId));
}

function getBaseX402SkillIdValue(body: Record<string, unknown>): 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(
Expand Down
Loading