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
75 changes: 75 additions & 0 deletions .agents/plans/validate-agent-public-route-pubkeys.plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
---
name: validate-agent-public-route-pubkeys
overview: "Reject malformed Solana public agent route parameters before identity or trust lookups can perform RPC or database work."
todos:
- id: confirm-route-boundary-gap
content: Confirm the identity and trust GET routes accept malformed Solana wallet path parameters and identify the shared validation helper
status: completed
- id: validate-public-agent-params
content: Validate the public identity and trust GET route pubkeys before downstream lookups
status: completed
- id: add-no-side-effect-regressions
content: Add route regressions proving malformed pubkeys return 400 without trust, identity, or dispute work
status: completed
- id: verify-focused-change
content: Run the focused tests plus format, lint, typecheck, full web tests, webpack build, and git whitespace checks
status: completed
isProject: false
---

# Validate Public Agent Route Public Keys

## Goal
Make public Solana agent identity and trust reads reject malformed wallet path parameters with a stable client error before they invoke trust, identity, or dispute helpers.

## Scope
- In scope: `GET /api/agents/[pubkey]/identity`, `GET /api/agents/[pubkey]/trust`, their adjacent tests, and this execution record.
- Out of scope: authenticated identity mutation behavior, GitHub-linking behavior, author-route changes, Base/EVM routing, database schema, RPC configuration, wallet authentication, and chain deployment.

## Files To Change
- `web/app/api/agents/[pubkey]/identity/route.ts`: validate the public GET path parameter before profile status and identity resolution.
- `web/app/api/agents/[pubkey]/trust/route.ts`: validate the public GET path parameter before trust, identity, or dispute reads.
- `web/__tests__/api/agent-identity-route.test.ts`: use a valid Solana fixture and add the malformed-param/no-side-effect regression.
- `web/__tests__/api/agent-trust-route.test.ts`: use a valid Solana fixture and add the malformed-param/no-side-effect regression.
- `.agents/plans/validate-agent-public-route-pubkeys.plan.md`: retain scoped evidence and exact validation results.

## Verified Gap (2026-09-04)
- `GET /api/agents/[pubkey]/identity` passes `pubkey` directly to `verifyAuthorTrust` and `resolveAgentIdentityByWallet` (`web/app/api/agents/[pubkey]/identity/route.ts:21-25`). A direct local handler probe with `not-a-wallet` returned `200` with a synthetic fallback identity rather than rejecting the invalid public key.
- `GET /api/agents/[pubkey]/trust` immediately calls `resolveAuthorTrust`, `resolveAgentIdentityByWallet`, and `listAuthorDisputesByAuthor` with its unchecked path parameter (`web/app/api/agents/[pubkey]/trust/route.ts:19-24`).
- `isValidChainAddress` validates a Solana address locally and is the established API-boundary helper (`web/lib/chainAddress.ts:62-72`); the configured Solana context is available from `getConfiguredSolanaChainContext`.
- Open PRs #157–#169 were checked on 2026-09-04. #169 hardens the distinct `/api/author/[pubkey]` route; none changes these public `/api/agents/[pubkey]` GET handlers.

## Implementation Steps
1. Import `getConfiguredSolanaChainContext` and `isValidChainAddress` in each public GET route.
2. Reject a malformed `pubkey` with `400 { error: "Agent routes require a valid Solana address" }` before calling any downstream helper.
3. Update success fixtures to a valid Solana public key, then assert malformed parameters perform no downstream lookup work.

## 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/agent-identity-route.test.ts __tests__/api/agent-trust-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: malformed public-agent path parameters return the documented `400`, make no downstream calls, valid Solana reads retain their responses, and the full local web gate passes.

### Execution Note (2026-09-04)
- Both public GET handlers now validate against `getConfiguredSolanaChainContext()` via the shared `isValidChainAddress` helper before invoking trust, identity, or dispute work.
- The focused regression suite passed: `__tests__/api/agent-identity-route.test.ts` plus `__tests__/api/agent-trust-route.test.ts` (17 tests). The change was developed test-first: each new malformed-param regression failed with the old `200` response before the handlers were updated.
- `npm run format:check`, `npm run lint:web`, and `npm run typecheck` passed. The full web suite passed (128 files, 929 tests), followed by `npm exec --workspace @agentvouch/web -- next build --webpack` and `git diff --check`.
- The build retained the repository's existing `ox` dynamic-dependency warning and expected static-generation `DATABASE_URL` fallback logs because local database credentials are absent. No live Solana RPC, database, browser, wallet, or deployment flow was run.

## Rollout
Ship as a focused request-boundary-hardening PR. No environment, schema, money-flow, chain deployment, or authenticated mutation change is included.

## Rollback
Revert the focused commit. No stored data or deployment state needs rollback.

## Blockers
- No live Solana RPC, database, browser, wallet, or deployment flow is required for this handler-boundary change; behavior will be verified by route tests.
21 changes: 18 additions & 3 deletions web/__tests__/api/agent-identity-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ const mockGithubSession = getGithubSessionFromRequest as unknown as ReturnType<
const mockVerifyAuthorTrust = verifyAuthorTrust as unknown as ReturnType<
typeof vi.fn
>;
const SOLANA_PUBKEY = "AGNtBjLEHFnssPzQjZJnnqiaUgtkaxj4fFaWoKD6yVdg";

function makeRequest(
path: string,
Expand All @@ -74,18 +75,32 @@ describe("/api/agents/[pubkey]/identity", () => {
mockResolveIdentity.mockResolvedValue({ username: "wallet-dmt4cd" });

const res = await GET(
makeRequest("/api/agents/Wallet111/identity", "GET"),
{ params: Promise.resolve({ pubkey: "Wallet111" }) }
makeRequest(`/api/agents/${SOLANA_PUBKEY}/identity`, "GET"),
{ params: Promise.resolve({ pubkey: SOLANA_PUBKEY }) }
);
const body = await res.json();

expect(res.status).toBe(200);
expect(body.author_identity.username).toBe("wallet-dmt4cd");
expect(mockResolveIdentity).toHaveBeenCalledWith("Wallet111", {
expect(mockResolveIdentity).toHaveBeenCalledWith(SOLANA_PUBKEY, {
hasAgentProfile: true,
});
});

it("rejects malformed public keys before trust or identity lookups", async () => {
const res = await GET(
makeRequest("/api/agents/not-a-solana-address/identity", "GET"),
{ params: Promise.resolve({ pubkey: "not-a-solana-address" }) }
);

expect(res.status).toBe(400);
await expect(res.json()).resolves.toEqual({
error: "Agent routes require a valid Solana address",
});
expect(mockVerifyAuthorTrust).not.toHaveBeenCalled();
expect(mockResolveIdentity).not.toHaveBeenCalled();
});

it("rejects username updates signed by another wallet", async () => {
mockVerifyWalletSignature.mockReturnValue({
valid: true,
Expand Down
26 changes: 21 additions & 5 deletions web/__tests__/api/agent-trust-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const mockResolveIdentity =
const mockListDisputes = listAuthorDisputesByAuthor as unknown as ReturnType<
typeof vi.fn
>;
const SOLANA_PUBKEY = "AGNtBjLEHFnssPzQjZJnnqiaUgtkaxj4fFaWoKD6yVdg";

describe("GET /api/agents/[pubkey]/trust", () => {
beforeEach(() => {
Expand All @@ -59,15 +60,15 @@ describe("GET /api/agents/[pubkey]/trust", () => {
mockListDisputes.mockResolvedValue([{ publicKey: "Dispute111" }]);

const request = new NextRequest(
"http://localhost/api/agents/Author111/trust"
`http://localhost/api/agents/${SOLANA_PUBKEY}/trust`
);
const response = await GET(request, {
params: Promise.resolve({ pubkey: "Author111" }),
params: Promise.resolve({ pubkey: SOLANA_PUBKEY }),
});
const body = await response.json();

expect(response.status).toBe(200);
expect(body.pubkey).toBe("Author111");
expect(body.pubkey).toBe(SOLANA_PUBKEY);
expect(body.trust.canonical_agent_id).toBe("agent-1");
expect(body.trust.recommended_action).toBe("allow");
expect(body.trust.isRegistered).toBe(true);
Expand Down Expand Up @@ -95,15 +96,30 @@ describe("GET /api/agents/[pubkey]/trust", () => {
mockListDisputes.mockResolvedValue([]);

const request = new NextRequest(
"http://localhost/api/agents/Author111/trust"
`http://localhost/api/agents/${SOLANA_PUBKEY}/trust`
);
const response = await GET(request, {
params: Promise.resolve({ pubkey: "Author111" }),
params: Promise.resolve({ pubkey: SOLANA_PUBKEY }),
});
const body = await response.json();

expect(response.status).toBe(200);
expect(body.trust.recommended_action).toBe("avoid");
expect(body.trust.isRegistered).toBe(false);
});

it("rejects malformed public keys before trust, identity, or dispute lookups", async () => {
const response = await GET(
new NextRequest("http://localhost/api/agents/not-a-solana-address/trust"),
{ params: Promise.resolve({ pubkey: "not-a-solana-address" }) }
);

expect(response.status).toBe(400);
await expect(response.json()).resolves.toEqual({
error: "Agent routes require a valid Solana address",
});
expect(mockResolveAuthorTrust).not.toHaveBeenCalled();
expect(mockResolveIdentity).not.toHaveBeenCalled();
expect(mockListDisputes).not.toHaveBeenCalled();
});
});
13 changes: 13 additions & 0 deletions web/app/api/agents/[pubkey]/identity/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
updateAgentUsername,
} from "@/lib/agentIdentity";
import { PRIVATE_NO_STORE_CACHE_CONTROL } from "@/lib/cachePolicy";
import { isValidChainAddress } from "@/lib/chainAddress";
import { getConfiguredSolanaChainContext } from "@/lib/chains";
import { getErrorMessage } from "@/lib/errors";
import { verifyAuthorTrust } from "@/lib/trust";

Expand All @@ -20,6 +22,17 @@ export async function GET(
) {
try {
const { pubkey } = await params;
if (
!isValidChainAddress({
chainContext: getConfiguredSolanaChainContext(),
value: pubkey,
})
) {
return NextResponse.json(
{ error: "Agent routes require a valid Solana address" },
{ status: 400 }
);
}
const authorIdentity = await resolveAgentIdentityByWallet(pubkey, {
hasAgentProfile: await getHasAgentProfile(pubkey),
});
Expand Down
13 changes: 13 additions & 0 deletions web/app/api/agents/[pubkey]/trust/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
PUBLIC_ROUTE_CACHE_SECONDS,
PUBLIC_ROUTE_STALE_SECONDS,
} from "@/lib/cachePolicy";
import { isValidChainAddress } from "@/lib/chainAddress";
import { getConfiguredSolanaChainContext } from "@/lib/chains";
import { getErrorMessage } from "@/lib/errors";

export async function GET(
Expand All @@ -17,6 +19,17 @@ export async function GET(
) {
try {
const { pubkey } = await params;
if (
!isValidChainAddress({
chainContext: getConfiguredSolanaChainContext(),
value: pubkey,
})
) {
return NextResponse.json(
{ error: "Agent routes require a valid Solana address" },
{ status: 400 }
);
}
const trust = await resolveAuthorTrust(pubkey);
const identity = await resolveAgentIdentityByWallet(pubkey, {
hasAgentProfile: trust.isRegistered,
Expand Down
Loading