diff --git a/.agents/plans/validate-author-route-pubkey.plan.md b/.agents/plans/validate-author-route-pubkey.plan.md new file mode 100644 index 00000000..f26648eb --- /dev/null +++ b/.agents/plans/validate-author-route-pubkey.plan.md @@ -0,0 +1,72 @@ +--- +name: validate-author-route-pubkey +overview: "Reject malformed Solana author route parameters before trust, dispute, and identity lookups can perform RPC or database work." +todos: + - id: confirm-route-boundary-gap + content: Confirm the current GET route accepts any non-EVM path value and identify the established chain-address validation helper + status: completed + - id: validate-solana-author-param + content: Validate non-EVM author route parameters before Solana trust, dispute, or identity lookups + status: completed + - id: add-no-side-effect-regression + content: Add a focused GET regression proving malformed Solana author parameters return 400 without downstream lookups + status: completed + - id: verify-focused-change + content: Run the targeted author route test plus format, lint, typecheck, full web tests, webpack build, and git whitespace checks + status: completed +isProject: false +--- + +# Validate Author Route Public Keys + +## Goal +Make `GET /api/author/[pubkey]` reject a malformed non-EVM path parameter with a client-error response before it can invoke Solana trust, dispute, or identity helpers. + +## Scope +- In scope: the public author GET boundary and its adjacent route test. +- Out of scope: author-trust semantics, Base/EVM lookup behavior, database schema, RPC configuration, wallet auth, and chain deployment work. + +## Files To Change +- `web/app/api/author/[pubkey]/route.ts`: validate the Solana-default branch with the shared chain-address helper before downstream work. +- `web/__tests__/api/author-route.test.ts`: cover the malformed-Solana-param `400` contract and no-side-effect invariant. + +## Verified Gap (2026-09-03) +- The route treats any non-EVM-shaped `pubkey` as the Solana path and immediately calls `resolveAuthorTrust`, `listAuthorDisputesByAuthor`, and `resolveAgentIdentityByWallet` (`web/app/api/author/[pubkey]/route.ts:88-100`). +- The shared `isValidChainAddress` helper already validates Solana addresses without adapter or network work (`web/lib/chainAddress.ts:62-72`), but this route does not use it. +- The route test covers successful Solana-style reads and EVM validation but has no malformed-Solana-path regression (`web/__tests__/api/author-route.test.ts:117-143`). +- As of 2026-09-03, no open PR changes these files; open PRs #157–#168 cover distinct request-body, ID, numeric-input, docs, and UI seams. + +## Implementation Steps +1. Import the shared chain-address validation helper and configured Solana chain context. +2. After the EVM branch, reject a non-Solana-valid `pubkey` with a stable `400` response before any trust, dispute, or identity helper runs. +3. Add one test using an invalid author path parameter. Assert the exact response and zero calls to the three downstream lookup mocks. + +## Verification +Run under the repository-required Node 24 environment: +```bash +. "$HOME/.nvm/nvm.sh" --no-use && { nvm use --silent || nvm install; } +npm test --workspace @agentvouch/web -- __tests__/api/author-route.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 +``` + +Acceptance criteria: a malformed Solana-default route parameter returns `400`, runs no downstream trust/dispute/identity lookup, valid Solana and Base author requests retain their contracts, and the full local web gate passes. + +### Execution Note (2026-09-03) +- The route now calls `isValidChainAddress` with the configured Solana context before Solana trust, dispute, or identity lookups. Base/EVM routing remains unchanged. +- The focused test passed: `web/__tests__/api/author-route.test.ts` (7 tests). +- `npm run format:check`, `npm run lint:web`, and `npm run typecheck` passed. The full web suite passed (128 files, 928 tests), followed by a successful `npm exec --workspace @agentvouch/web -- next build --webpack` and `git diff --check`. +- The webpack build retained existing `ox`/`viem` dynamic-dependency warnings and expected static-generation `DATABASE_URL` fallback logs because local database credentials are absent. No live Solana RPC, browser, wallet, database, or deployment flow was run. + +## Rollout +Ship as a focused public request-boundary hardening PR. It adds no schema, environment, money-flow, or chain deployment changes. + +## Rollback +Revert the focused commit to restore the prior public-route behavior. No stored data or deployment state requires rollback. + +## Blockers +- No live Solana RPC or browser flow will be run; this is verified through route-level behavior and no-side-effect tests. diff --git a/web/__tests__/api/author-route.test.ts b/web/__tests__/api/author-route.test.ts index 49276abf..171b9216 100644 --- a/web/__tests__/api/author-route.test.ts +++ b/web/__tests__/api/author-route.test.ts @@ -128,9 +128,10 @@ describe("POST /api/author/[pubkey]", () => { mockResolveIdentity.mockResolvedValue({ canonicalAgentId: "agent-1" }); mockListAuthorDisputes.mockResolvedValue([{ publicKey: "Dispute111" }]); - const req = new NextRequest("http://localhost/api/author/Author111"); + const pubkey = "AGNtBjLEHFnssPzQjZJnnqiaUgtkaxj4fFaWoKD6yVdg"; + const req = new NextRequest(`http://localhost/api/author/${pubkey}`); const res = await GET(req, { - params: Promise.resolve({ pubkey: "Author111" }), + params: Promise.resolve({ pubkey }), }); const body = await res.json(); @@ -142,6 +143,21 @@ describe("POST /api/author/[pubkey]", () => { expect(body.author_disputes).toEqual([{ publicKey: "Dispute111" }]); }); + it("rejects malformed Solana author parameters before downstream lookups", async () => { + const res = await GET( + new NextRequest("http://localhost/api/author/not-a-solana-address"), + { params: Promise.resolve({ pubkey: "not-a-solana-address" }) } + ); + + expect(res.status).toBe(400); + await expect(res.json()).resolves.toEqual({ + error: "Solana author routes require a valid Solana address", + }); + expect(mockResolveAuthorTrust).not.toHaveBeenCalled(); + expect(mockListAuthorDisputes).not.toHaveBeenCalled(); + expect(mockResolveIdentity).not.toHaveBeenCalled(); + }); + it("returns 400 when the selected discovered candidate does not belong to the wallet", async () => { mockVerify.mockReturnValue({ valid: true, pubkey: "Author111" }); mockVerifyAuthorTrust.mockResolvedValue({ isRegistered: true }); diff --git a/web/app/api/author/[pubkey]/route.ts b/web/app/api/author/[pubkey]/route.ts index 9d6af309..942403b8 100644 --- a/web/app/api/author/[pubkey]/route.ts +++ b/web/app/api/author/[pubkey]/route.ts @@ -10,8 +10,10 @@ import { discoverSolanaRegistryCandidatesByWallet } from "@/lib/solanaAgentRegis import { listAuthorDisputesByAuthor } from "@/lib/authorDisputes"; import { BASE_SEPOLIA_CHAIN_CONTEXT, + getConfiguredSolanaChainContext, normalizeInputChainContext, } from "@/lib/chains"; +import { isValidChainAddress } from "@/lib/chainAddress"; import { buildPublicCacheControl, PUBLIC_ROUTE_CACHE_SECONDS, @@ -85,6 +87,18 @@ export async function GET( ); } + if ( + !isValidChainAddress({ + chainContext: getConfiguredSolanaChainContext(), + value: pubkey, + }) + ) { + return NextResponse.json( + { error: "Solana author routes require a valid Solana address" }, + { status: 400 } + ); + } + const authorTrust = await resolveAuthorTrust(pubkey); const authorDisputes = await listAuthorDisputesByAuthor(pubkey); let authorIdentity = null;