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
3 changes: 3 additions & 0 deletions website/app/a2a/[id]/agent-card.json/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { GET } from "../route";

export const runtime = "nodejs";
22 changes: 22 additions & 0 deletions website/app/a2a/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import {
agentCardResponse,
notFound,
resolveA2aAgentCard,
} from "@src/common/a2a-route";

type RouteContext = {
params: Promise<{
id: string;
}>;
};

export const runtime = "nodejs";

export async function GET(
_request: Request,
{ params }: RouteContext
): Promise<Response> {
const { id } = await params;
const card = await resolveA2aAgentCard(id);
return card ? agentCardResponse(card) : notFound();
}
Comment on lines +15 to +22

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

GET handler lacks try-catch; errors from resolveA2aAgentCard produce unhandled 500s.

If resolveA2aAgentCard throws (network error, malformed JSON from directory API), the error propagates unhandled. Per coding guidelines, all async functions must have try-catch. The same issue exists in skill.md/route.ts, swagger.json/route.ts, and swagger.md/route.ts.

🛡️ Proposed fix
 export async function GET(
 	_request: Request,
 	{ params }: RouteContext
 ): Promise<Response> {
-	const { id } = await params;
-	const card = await resolveA2aAgentCard(id);
-	return card ? agentCardResponse(card) : notFound();
+	try {
+		const { id } = await params;
+		const card = await resolveA2aAgentCard(id);
+		return card ? agentCardResponse(card) : notFound();
+	} catch {
+		return new Response("Internal server error", { status: 500 });
+	}
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export async function GET(
_request: Request,
{ params }: RouteContext
): Promise<Response> {
const { id } = await params;
const card = await resolveA2aAgentCard(id);
return card ? agentCardResponse(card) : notFound();
}
export async function GET(
_request: Request,
{ params }: RouteContext
): Promise<Response> {
try {
const { id } = await params;
const card = await resolveA2aAgentCard(id);
return card ? agentCardResponse(card) : notFound();
} catch {
return new Response("Internal server error", { status: 500 });
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@website/app/a2a/`[id]/route.ts around lines 15 - 22, Wrap the asynchronous
logic in the GET handler around resolveA2aAgentCard with try-catch and return
the existing error response used by the route on failure, while preserving the
notFound response for missing cards. Apply the same handling to the GET handlers
in skill.md/route.ts, swagger.json/route.ts, and swagger.md/route.ts.

Source: Coding guidelines

22 changes: 22 additions & 0 deletions website/app/a2a/[id]/skill.md/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import {
agentDocResponse,
notFound,
resolveA2aAgentCard,
} from "@src/common/a2a-route";

type RouteContext = {
params: Promise<{
id: string;
}>;
};

export const runtime = "nodejs";

export async function GET(
_request: Request,
{ params }: RouteContext
): Promise<Response> {
const { id } = await params;
const card = await resolveA2aAgentCard(id);
return card ? agentDocResponse(card, "skillMd") : notFound();
}
22 changes: 22 additions & 0 deletions website/app/a2a/[id]/swagger.json/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import {
agentDocResponse,
notFound,
resolveA2aAgentCard,
} from "@src/common/a2a-route";

type RouteContext = {
params: Promise<{
id: string;
}>;
};

export const runtime = "nodejs";

export async function GET(
_request: Request,
{ params }: RouteContext
): Promise<Response> {
const { id } = await params;
const card = await resolveA2aAgentCard(id);
return card ? agentDocResponse(card, "swaggerJson") : notFound();
}
22 changes: 22 additions & 0 deletions website/app/a2a/[id]/swagger.md/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import {
agentDocResponse,
notFound,
resolveA2aAgentCard,
} from "@src/common/a2a-route";

type RouteContext = {
params: Promise<{
id: string;
}>;
};

export const runtime = "nodejs";

export async function GET(
_request: Request,
{ params }: RouteContext
): Promise<Response> {
const { id } = await params;
const card = await resolveA2aAgentCard(id);
return card ? agentDocResponse(card, "swaggerMd") : notFound();
}
96 changes: 96 additions & 0 deletions website/src/common/a2a-route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { describe, expect, it, vi } from "vitest";

import {
type A2aAgentCard,
agentDocResponse,

Check failure on line 5 in website/src/common/a2a-route.test.ts

View workflow job for this annotation

GitHub Actions / Lint

The variable `agentDocResponse` should be named `agentDocumentResponse`. A more descriptive name will do too
agentDocUrl,

Check failure on line 6 in website/src/common/a2a-route.test.ts

View workflow job for this annotation

GitHub Actions / Lint

The variable `agentDocUrl` should be named `agentDocumentUrl`. A more descriptive name will do too
resolveA2aAgentCard,
} from "./a2a-route";

const baseCard: A2aAgentCard = {
agentId: "A8sVmcaC5apxoUx1kCA4pPa9RttQK1HXmfkziSFf5dVg",
createdAt: "2026-07-11T00:00:00Z",
cryptoId: "A8sVmcaC5apxoUx1kCA4pPa9RttQK1HXmfkziSFf5dVg",
name: "NatureDesk",
updatedAt: "2026-07-11T00:00:00Z",
url: "https://naturedesk.github.io/site/a2a/@naturedesk/agent-card.json",
};

describe("A2A route helpers", () => {
it("resolves @handles through the directory before fetching the agent card", async () => {
const fetcher = vi.fn(async (input: RequestInfo | URL) => {

Check failure on line 21 in website/src/common/a2a-route.test.ts

View workflow job for this annotation

GitHub Actions / Lint

Async arrow function has no 'await' expression
const url = String(input);

Check failure on line 22 in website/src/common/a2a-route.test.ts

View workflow job for this annotation

GitHub Actions / Lint

'input' may use Object's default stringification format ('[object Object]') when stringified
if (url.endsWith("/directory/resolve/%40naturedesk")) {
return Response.json({
identity: { cryptoId: baseCard.agentId },
});
}
if (url.endsWith(`/directory/agents/${baseCard.agentId}`)) {
return Response.json(baseCard);
}
return Response.json({ error: "unexpected" }, { status: 500 });
});

await expect(
resolveA2aAgentCard(
"@naturedesk",
fetcher as typeof fetch,
"https://api.example.test"
)
).resolves.toMatchObject({ name: "NatureDesk" });
expect(fetcher).toHaveBeenCalledTimes(2);
});

it("derives docs from an external agent-card URL when no docs URL is advertised", () => {
expect(agentDocUrl(baseCard, "skillMd")).toBe(
"https://naturedesk.github.io/site/a2a/@naturedesk/skill.md"
);
});

it("redirects to advertised skill markdown without proxying it", async () => {
const card: A2aAgentCard = {
...baseCard,
docs: {
skillMdUrl: "https://docs.example.test/skill.md",
},
};
const fetcher = vi.fn();

const response = await agentDocResponse(card, "skillMd", fetcher as typeof fetch);
expect(response.status).toBe(302);
expect(response.headers.get("Location")).toBe(
"https://docs.example.test/skill.md"
);
expect(fetcher).not.toHaveBeenCalled();
});

it("redirects to relative advertised docs URLs", async () => {
const card: A2aAgentCard = {
...baseCard,
docs: {
skillMdUrl: "/a2a/@naturedesk/skill.md",
},
};
const fetcher = vi.fn();

expect(agentDocUrl(card, "skillMd")).toBe("/a2a/@naturedesk/skill.md");
const response = await agentDocResponse(card, "skillMd", fetcher as typeof fetch);
expect(response.status).toBe(302);
expect(response.headers.get("Location")).toBe("/a2a/@naturedesk/skill.md");
expect(fetcher).not.toHaveBeenCalled();
});

it("serves inline skill markdown without fetching", async () => {
const card: A2aAgentCard = {
...baseCard,
docs: {
skillMd: "# Inline skill\n",
},
};
const fetcher = vi.fn();

const response = await agentDocResponse(card, "skillMd", fetcher as typeof fetch);
await expect(response.text()).resolves.toBe("# Inline skill\n");
expect(fetcher).not.toHaveBeenCalled();
});
});
188 changes: 188 additions & 0 deletions website/src/common/a2a-route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
type Fetcher = typeof fetch;

export const A2A_API_BASE_URL =
process.env["NEXT_PUBLIC_API_BASE_URL"] ?? "https://staging-api.tiny.place";

export type A2aAgentCard = {
agentId: string;
cryptoId: string;
docs?: {
skillMd?: string;
skillMdUrl?: string;
swaggerJson?: string;
swaggerJsonUrl?: string;
swaggerMd?: string;
swaggerMdUrl?: string;
};
name: string;
url?: string;
[key: string]: unknown;
};

type ResolveResponse = {
agent?: A2aAgentCard | null;
identity?: { cryptoId?: string | null } | null;
};

type DocKind = "skillMd" | "swaggerJson" | "swaggerMd";

Check failure on line 27 in website/src/common/a2a-route.ts

View workflow job for this annotation

GitHub Actions / Lint

The variable `DocKind` should be named `DocumentKind`. A more descriptive name will do too

const DOC_FILENAMES: Record<DocKind, string> = {
skillMd: "skill.md",
swaggerJson: "swagger.json",
swaggerMd: "swagger.md",
};

const DOC_CONTENT_TYPES: Record<DocKind, string> = {
skillMd: "text/markdown; charset=utf-8",
swaggerJson: "application/json; charset=utf-8",
swaggerMd: "text/markdown; charset=utf-8",
};

export async function resolveA2aAgentCard(
id: string,
fetcher: Fetcher = fetch,
apiBaseUrl = A2A_API_BASE_URL
): Promise<A2aAgentCard | null> {
const normalized = decodeRouteId(id);

Check failure on line 46 in website/src/common/a2a-route.ts

View workflow job for this annotation

GitHub Actions / Lint

'decodeRouteId' was used before it was defined
if (normalized === "") {
return null;
}

if (normalized.startsWith("@")) {
const resolved = await fetchJson<ResolveResponse>(

Check failure on line 52 in website/src/common/a2a-route.ts

View workflow job for this annotation

GitHub Actions / Lint

'fetchJson' was used before it was defined
`${apiBaseUrl}/directory/resolve/${encodeURIComponent(normalized)}`,
fetcher
);
if (!resolved) {
return null;
}
if (resolved.agent) {
return resolved.agent;
}
const cryptoId = resolved.identity?.cryptoId;
return cryptoId ? fetchAgentCard(cryptoId, fetcher, apiBaseUrl) : null;

Check failure on line 63 in website/src/common/a2a-route.ts

View workflow job for this annotation

GitHub Actions / Lint

'fetchAgentCard' was used before it was defined
}

return fetchAgentCard(normalized, fetcher, apiBaseUrl);

Check failure on line 66 in website/src/common/a2a-route.ts

View workflow job for this annotation

GitHub Actions / Lint

'fetchAgentCard' was used before it was defined
}

export function agentCardResponse(card: A2aAgentCard): Response {
return Response.json(card, {
headers: {
"Cache-Control": "public, s-maxage=60, stale-while-revalidate=300",
},
});
}

export async function agentDocResponse(

Check failure on line 77 in website/src/common/a2a-route.ts

View workflow job for this annotation

GitHub Actions / Lint

Async function 'agentDocResponse' has no 'await' expression
card: A2aAgentCard,
kind: DocKind,
_fetcher: Fetcher = fetch
): Promise<Response> {
const inline = inlineDoc(card, kind);
if (inline !== undefined) {
return new Response(inline, {
headers: {
"Cache-Control": "public, s-maxage=60, stale-while-revalidate=300",
"Content-Type": DOC_CONTENT_TYPES[kind],
},
});
}

const url = agentDocUrl(card, kind);
if (!url) {
return notFound();
}

return new Response(null, {
status: 302,
headers: {
"Cache-Control": "public, s-maxage=60, stale-while-revalidate=300",
Location: url,
},
});
Comment on lines +92 to +103

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Self-referential redirect can cause infinite redirect loops.

If an agent card advertises a doc URL that resolves to the current route (e.g., docs.skillMdUrl: "/a2a/@naturedesk/skill.md"), agentDocResponse returns a 302 to the same URL, causing an infinite redirect loop. Each iteration re-resolves the card and makes directory API calls, amplifying load. The route handlers ignore _request, so they can't detect this.

Consider passing the request URL to agentDocResponse (or checking in the route handler) and returning notFound() when the redirect target matches the current request.

🔧 Proposed approach
 export async function agentDocResponse(
 	card: A2aAgentCard,
 	kind: DocKind,
-	_fetcher: Fetcher = fetch
+	_fetcher: Fetcher = fetch,
+	requestUrl?: string
 ): Promise<Response> {
 	const inline = inlineDoc(card, kind);
 	if (inline !== undefined) {
 		return new Response(inline, {
 			headers: {
 				"Cache-Control": "public, s-maxage=60, stale-while-revalidate=300",
 				"Content-Type": DOC_CONTENT_TYPES[kind],
 			},
 		});
 	}

 	const url = agentDocUrl(card, kind);
 	if (!url) {
 		return notFound();
 	}

+	if (requestUrl && resolvesToSameUrl(url, requestUrl)) {
+		return notFound();
+	}
+
 	return new Response(null, {
 		status: 302,
 		headers: {
 			"Cache-Control": "public, s-maxage=60, stale-while-revalidate=300",
 			Location: url,
 		},
 	});
 }

Then update route handlers to pass the request URL:

 export async function GET(
-	_request: Request,
+	request: Request,
 	{ params }: RouteContext
 ): Promise<Response> {
 	const { id } = await params;
 	const card = await resolveA2aAgentCard(id);
-	return card ? agentDocResponse(card, "skillMd") : notFound();
+	return card ? agentDocResponse(card, "skillMd", fetch, request.url) : notFound();
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@website/src/common/a2a-route.ts` around lines 92 - 103, Update
agentDocResponse and its route-handler callers to receive the current request
URL, compare the resolved agentDocUrl target against that URL, and return
notFound() instead of issuing a 302 when they match. Ensure both relevant
handlers pass their request URL while preserving normal redirects for different
targets.

}

export function agentDocUrl(card: A2aAgentCard, kind: DocKind): string | null {
const docs = card.docs;
const urlField = `${kind}Url` as keyof NonNullable<A2aAgentCard["docs"]>;
const advertisedUrl = docs?.[urlField];
if (typeof advertisedUrl === "string" && isDocUrl(advertisedUrl)) {
return advertisedUrl;
}

const advertisedValue = docs?.[kind];
if (typeof advertisedValue === "string" && isDocUrl(advertisedValue)) {
return advertisedValue;
}

if (card.url && isHttpUrl(card.url)) {
return new URL(DOC_FILENAMES[kind], card.url).toString();
}

return null;
}

export function notFound(): Response {
return new Response("Not found", {
status: 404,
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
}

function inlineDoc(card: A2aAgentCard, kind: DocKind): string | undefined {
const value = card.docs?.[kind];
if (typeof value === "string" && !isDocUrl(value)) {
return value;
}
return undefined;
}

async function fetchAgentCard(
agentId: string,
fetcher: Fetcher,
apiBaseUrl: string
): Promise<A2aAgentCard | null> {
return fetchJson<A2aAgentCard>(
`${apiBaseUrl}/directory/agents/${encodeURIComponent(agentId)}`,
fetcher
);
}

async function fetchJson<T>(url: string, fetcher: Fetcher): Promise<T | null> {
const response = await fetcher(url, {
headers: { Accept: "application/json" },
});
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error(`A2A directory lookup failed: HTTP ${response.status}`);
}
return (await response.json()) as T;
}
Comment on lines +152 to +163

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

fetchJson lacks error handling for network and JSON parse failures.

fetcher() can throw on network errors and response.json() can throw on malformed JSON. Without try-catch, these errors propagate unhandled through resolveA2aAgentCard to the route handlers, producing generic 500s. Per coding guidelines, all async functions must have proper error handling with try-catch blocks.

🛡️ Proposed fix
 async function fetchJson<T>(url: string, fetcher: Fetcher): Promise<T | null> {
-	const response = await fetcher(url, {
-		headers: { Accept: "application/json" },
-	});
-	if (response.status === 404) {
-		return null;
-	}
-	if (!response.ok) {
-		throw new Error(`A2A directory lookup failed: HTTP ${response.status}`);
-	}
-	return (await response.json()) as T;
+	try {
+		const response = await fetcher(url, {
+			headers: { Accept: "application/json" },
+		});
+		if (response.status === 404) {
+			return null;
+		}
+		if (!response.ok) {
+			throw new Error(`A2A directory lookup failed: HTTP ${response.status}`);
+		}
+		return (await response.json()) as T;
+	} catch (error) {
+		if (error instanceof Error && error.message.startsWith("A2A directory")) {
+			throw error;
+		}
+		return null;
+	}
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async function fetchJson<T>(url: string, fetcher: Fetcher): Promise<T | null> {
const response = await fetcher(url, {
headers: { Accept: "application/json" },
});
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error(`A2A directory lookup failed: HTTP ${response.status}`);
}
return (await response.json()) as T;
}
async function fetchJson<T>(url: string, fetcher: Fetcher): Promise<T | null> {
try {
const response = await fetcher(url, {
headers: { Accept: "application/json" },
});
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error(`A2A directory lookup failed: HTTP ${response.status}`);
}
return (await response.json()) as T;
} catch (error) {
if (error instanceof Error && error.message.startsWith("A2A directory")) {
throw error;
}
return null;
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@website/src/common/a2a-route.ts` around lines 152 - 163, Update fetchJson to
wrap the fetcher call, HTTP validation, and response.json parsing in try-catch
handling; preserve the null return for 404 responses, and catch network or
malformed-JSON failures to log or return the established safe fallback used by
resolveA2aAgentCard instead of letting errors propagate to route handlers.

Source: Coding guidelines


function decodeRouteId(id: string): string {
try {
return decodeURIComponent(id).trim();
} catch {
return id.trim();
}
}

function isHttpUrl(value: string): boolean {
try {
const url = new URL(value);
return url.protocol === "http:" || url.protocol === "https:";
} catch {
return false;
}
}

function isDocUrl(value: string): boolean {
return isHttpUrl(value) || isRelativePath(value);
}

function isRelativePath(value: string): boolean {
return value.startsWith("/") && !value.startsWith("//");
}
Loading