From 79885324965ffb1224b5a38b51942e1ed1356b31 Mon Sep 17 00:00:00 2001 From: NatureDesk Date: Sat, 11 Jul 2026 11:07:50 +0200 Subject: [PATCH 1/2] fix: serve website A2A handle routes --- website/app/a2a/[id]/agent-card.json/route.ts | 3 + website/app/a2a/[id]/route.ts | 22 +++ website/app/a2a/[id]/skill.md/route.ts | 22 +++ website/app/a2a/[id]/swagger.json/route.ts | 22 +++ website/app/a2a/[id]/swagger.md/route.ts | 22 +++ website/src/common/a2a-route.test.ts | 84 ++++++++ website/src/common/a2a-route.ts | 187 ++++++++++++++++++ 7 files changed, 362 insertions(+) create mode 100644 website/app/a2a/[id]/agent-card.json/route.ts create mode 100644 website/app/a2a/[id]/route.ts create mode 100644 website/app/a2a/[id]/skill.md/route.ts create mode 100644 website/app/a2a/[id]/swagger.json/route.ts create mode 100644 website/app/a2a/[id]/swagger.md/route.ts create mode 100644 website/src/common/a2a-route.test.ts create mode 100644 website/src/common/a2a-route.ts diff --git a/website/app/a2a/[id]/agent-card.json/route.ts b/website/app/a2a/[id]/agent-card.json/route.ts new file mode 100644 index 00000000..4bf3156b --- /dev/null +++ b/website/app/a2a/[id]/agent-card.json/route.ts @@ -0,0 +1,3 @@ +export { GET } from "../route"; + +export const runtime = "nodejs"; diff --git a/website/app/a2a/[id]/route.ts b/website/app/a2a/[id]/route.ts new file mode 100644 index 00000000..3420ec5c --- /dev/null +++ b/website/app/a2a/[id]/route.ts @@ -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 { + const { id } = await params; + const card = await resolveA2aAgentCard(id); + return card ? agentCardResponse(card) : notFound(); +} diff --git a/website/app/a2a/[id]/skill.md/route.ts b/website/app/a2a/[id]/skill.md/route.ts new file mode 100644 index 00000000..2f49f00a --- /dev/null +++ b/website/app/a2a/[id]/skill.md/route.ts @@ -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 { + const { id } = await params; + const card = await resolveA2aAgentCard(id); + return card ? agentDocResponse(card, "skillMd") : notFound(); +} diff --git a/website/app/a2a/[id]/swagger.json/route.ts b/website/app/a2a/[id]/swagger.json/route.ts new file mode 100644 index 00000000..0fe14604 --- /dev/null +++ b/website/app/a2a/[id]/swagger.json/route.ts @@ -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 { + const { id } = await params; + const card = await resolveA2aAgentCard(id); + return card ? agentDocResponse(card, "swaggerJson") : notFound(); +} diff --git a/website/app/a2a/[id]/swagger.md/route.ts b/website/app/a2a/[id]/swagger.md/route.ts new file mode 100644 index 00000000..c14c982a --- /dev/null +++ b/website/app/a2a/[id]/swagger.md/route.ts @@ -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 { + const { id } = await params; + const card = await resolveA2aAgentCard(id); + return card ? agentDocResponse(card, "swaggerMd") : notFound(); +} diff --git a/website/src/common/a2a-route.test.ts b/website/src/common/a2a-route.test.ts new file mode 100644 index 00000000..e12384a1 --- /dev/null +++ b/website/src/common/a2a-route.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + type A2aAgentCard, + agentDocResponse, + agentDocUrl, + 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) => { + const url = String(input); + 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("proxies advertised skill markdown", async () => { + const card: A2aAgentCard = { + ...baseCard, + docs: { + skillMdUrl: "https://docs.example.test/skill.md", + }, + }; + const fetcher = vi.fn(async () => + new Response("# NatureDesk\n", { + headers: { "Content-Type": "text/markdown" }, + }) + ); + + const response = await agentDocResponse(card, "skillMd", fetcher as typeof fetch); + await expect(response.text()).resolves.toBe("# NatureDesk\n"); + expect(response.headers.get("Content-Type")).toBe("text/markdown"); + expect(fetcher).toHaveBeenCalledWith("https://docs.example.test/skill.md", { + headers: { Accept: "text/markdown; charset=utf-8" }, + }); + }); + + 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(); + }); +}); diff --git a/website/src/common/a2a-route.ts b/website/src/common/a2a-route.ts new file mode 100644 index 00000000..e78faca0 --- /dev/null +++ b/website/src/common/a2a-route.ts @@ -0,0 +1,187 @@ +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"; + +const DOC_FILENAMES: Record = { + skillMd: "skill.md", + swaggerJson: "swagger.json", + swaggerMd: "swagger.md", +}; + +const DOC_CONTENT_TYPES: Record = { + 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 { + const normalized = decodeRouteId(id); + if (normalized === "") { + return null; + } + + if (normalized.startsWith("@")) { + const resolved = await fetchJson( + `${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; + } + + return fetchAgentCard(normalized, fetcher, apiBaseUrl); +} + +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( + card: A2aAgentCard, + kind: DocKind, + fetcher: Fetcher = fetch +): Promise { + 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(); + } + + const upstream = await fetcher(url, { + headers: { Accept: DOC_CONTENT_TYPES[kind] }, + }); + if (!upstream.ok) { + return notFound(); + } + const body = await upstream.text(); + return new Response(body, { + headers: { + "Cache-Control": "public, s-maxage=60, stale-while-revalidate=300", + "Content-Type": + upstream.headers.get("Content-Type") ?? DOC_CONTENT_TYPES[kind], + }, + }); +} + +export function agentDocUrl(card: A2aAgentCard, kind: DocKind): string | null { + const docs = card.docs; + const urlField = `${kind}Url` as keyof NonNullable; + const advertisedUrl = docs?.[urlField]; + if (typeof advertisedUrl === "string" && isHttpUrl(advertisedUrl)) { + return advertisedUrl; + } + + const advertisedValue = docs?.[kind]; + if (typeof advertisedValue === "string" && isHttpUrl(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" && !isHttpUrl(value)) { + return value; + } + return undefined; +} + +async function fetchAgentCard( + agentId: string, + fetcher: Fetcher, + apiBaseUrl: string +): Promise { + return fetchJson( + `${apiBaseUrl}/directory/agents/${encodeURIComponent(agentId)}`, + fetcher + ); +} + +async function fetchJson(url: string, fetcher: Fetcher): Promise { + 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; +} + +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; + } +} From 130b1c1243c2ac60648a77b9476ab308189c930c Mon Sep 17 00:00:00 2001 From: NatureDesk Date: Sun, 12 Jul 2026 21:57:07 +0200 Subject: [PATCH 2/2] fix: avoid server-side docs proxying --- website/src/common/a2a-route.test.ts | 32 +++++++++++++++++++--------- website/src/common/a2a-route.ts | 29 +++++++++++++------------ 2 files changed, 37 insertions(+), 24 deletions(-) diff --git a/website/src/common/a2a-route.test.ts b/website/src/common/a2a-route.test.ts index e12384a1..dacc4487 100644 --- a/website/src/common/a2a-route.test.ts +++ b/website/src/common/a2a-route.test.ts @@ -47,25 +47,37 @@ describe("A2A route helpers", () => { ); }); - it("proxies advertised skill markdown", async () => { + 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(async () => - new Response("# NatureDesk\n", { - headers: { "Content-Type": "text/markdown" }, - }) + 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); - await expect(response.text()).resolves.toBe("# NatureDesk\n"); - expect(response.headers.get("Content-Type")).toBe("text/markdown"); - expect(fetcher).toHaveBeenCalledWith("https://docs.example.test/skill.md", { - headers: { Accept: "text/markdown; charset=utf-8" }, - }); + 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 () => { diff --git a/website/src/common/a2a-route.ts b/website/src/common/a2a-route.ts index e78faca0..11a60ca1 100644 --- a/website/src/common/a2a-route.ts +++ b/website/src/common/a2a-route.ts @@ -77,7 +77,7 @@ export function agentCardResponse(card: A2aAgentCard): Response { export async function agentDocResponse( card: A2aAgentCard, kind: DocKind, - fetcher: Fetcher = fetch + _fetcher: Fetcher = fetch ): Promise { const inline = inlineDoc(card, kind); if (inline !== undefined) { @@ -94,18 +94,11 @@ export async function agentDocResponse( return notFound(); } - const upstream = await fetcher(url, { - headers: { Accept: DOC_CONTENT_TYPES[kind] }, - }); - if (!upstream.ok) { - return notFound(); - } - const body = await upstream.text(); - return new Response(body, { + return new Response(null, { + status: 302, headers: { "Cache-Control": "public, s-maxage=60, stale-while-revalidate=300", - "Content-Type": - upstream.headers.get("Content-Type") ?? DOC_CONTENT_TYPES[kind], + Location: url, }, }); } @@ -114,12 +107,12 @@ export function agentDocUrl(card: A2aAgentCard, kind: DocKind): string | null { const docs = card.docs; const urlField = `${kind}Url` as keyof NonNullable; const advertisedUrl = docs?.[urlField]; - if (typeof advertisedUrl === "string" && isHttpUrl(advertisedUrl)) { + if (typeof advertisedUrl === "string" && isDocUrl(advertisedUrl)) { return advertisedUrl; } const advertisedValue = docs?.[kind]; - if (typeof advertisedValue === "string" && isHttpUrl(advertisedValue)) { + if (typeof advertisedValue === "string" && isDocUrl(advertisedValue)) { return advertisedValue; } @@ -139,7 +132,7 @@ export function notFound(): Response { function inlineDoc(card: A2aAgentCard, kind: DocKind): string | undefined { const value = card.docs?.[kind]; - if (typeof value === "string" && !isHttpUrl(value)) { + if (typeof value === "string" && !isDocUrl(value)) { return value; } return undefined; @@ -185,3 +178,11 @@ function isHttpUrl(value: string): boolean { return false; } } + +function isDocUrl(value: string): boolean { + return isHttpUrl(value) || isRelativePath(value); +} + +function isRelativePath(value: string): boolean { + return value.startsWith("/") && !value.startsWith("//"); +}