/* RFC-0783: pure, dependency-free formatter that projects a site's Agent Surface Manifest into an RFC 9727 linkset+json document for /.well-known/api-catalog. No I/O — the kernel command loads the manifest and passes it here. Do not read files — callers load and pass the manifest. Do not sign — signing is a separate concern (agent.surface.sign). RFC-1114: link agent-card.json (service-desc) + a2a endpoint (service) when interfaces.a2a is non-null. RFC-1114: minimal real A2A endpoint + honest agent card RFC-1171: successor-version link entries per manifest deprecations[] — deprecated aliases anchored, pointing at canonical successors. RFC-1187: phases 5-7 — OAuth route/discovery generators, env schema, operator commands RFC-1187: fo-review — oauth metadata links fold into registered service-meta (no oauth-* relation exists; RFC 8415 reference was wrong — relations live in RFC 8288's registry) RFC-0783, RFC-0954 */ import type { AgentSurfaceManifest } from "./manifest.ts"; /** RFC 9264 linkset+json link object — no `rel` (it's the key in the linkset entry). */ export interface ApiCatalogLink { href: string; type: string; title?: string; } /** RFC 9264 linkset entry — anchor + relation-keyed link arrays. */ export interface ApiCatalogEntry { anchor: string; [relation: string]: string | ApiCatalogLink[]; } /** RFC 9264 linkset document — top-level `linkset` array. */ export interface ApiCatalog { linkset: ApiCatalogEntry[]; } /** * Pure: project Agent Surface Manifest into RFC 9264 linkset+json. * Deterministic — links within each relation are sorted by href for byte-identical output (DNA-58). */ export function buildApiCatalog(manifest: AgentSurfaceManifest): ApiCatalog { const anchor = manifest.baseUrl.replace(/\/+$/, "") + "/"; const entry: ApiCatalogEntry = { anchor }; const item: ApiCatalogLink[] = manifest.knowledge.map((ref) => ({ href: ref.url, type: "application/json", title: ref.domain, })); if (item.length > 0) { item.sort((a, b) => a.href.localeCompare(b.href)); entry["item"] = item; } // RFC-1187: the AS + protected-resource metadata documents ride the // registered service-meta relation (RFC 8288) — there is no registered // oauth-* link relation, and RFC 8288 requires extension relations be URIs. const serviceMeta: ApiCatalogLink[] = [ { href: "/.well-known/agent.json", type: "application/json" }, ]; if (manifest.interfaces.oauth) { serviceMeta.push( { href: manifest.interfaces.oauth.authorizationServer, type: "application/json" }, { href: manifest.interfaces.oauth.protectedResource, type: "application/json" }, ); } entry["service-meta"] = serviceMeta; const serviceDesc: ApiCatalogLink[] = []; if (manifest.interfaces.openapi) { serviceDesc.push({ href: manifest.interfaces.openapi, type: "application/json" }); } if (manifest.interfaces.mcp) { serviceDesc.push({ href: "/.well-known/mcp/server-card.json", type: "application/json" }); } if (manifest.interfaces.a2a) { serviceDesc.push({ href: "/.well-known/agent-card.json", type: "application/json" }); } if (serviceDesc.length > 0) { serviceDesc.sort((a, b) => a.href.localeCompare(b.href)); entry["service-desc"] = serviceDesc; } const service: ApiCatalogLink[] = []; if (manifest.interfaces.mcp) { service.push({ href: manifest.interfaces.mcp.url, type: "application/json" }); } if (manifest.interfaces.a2a) { service.push({ href: manifest.interfaces.a2a.url, type: "application/json" }); } if (service.length > 0) { service.sort((a, b) => a.href.localeCompare(b.href)); entry["service"] = service; } if (manifest.interfaces.search) { entry["search"] = [{ href: manifest.interfaces.search.url, type: "application/json" }]; } entry["service-doc"] = [{ href: manifest.interfaces.llms, type: "text/plain" }]; // RFC-1171: deprecated alias paths appear as successor-version links — // one entry per covered action, anchored at the deprecated concrete path. const deprecationEntries: ApiCatalogEntry[] = []; for (const dep of manifest.deprecations ?? []) { for (const ref of manifest.actions) { const successorUrl = dep.successor.replaceAll("{id}", ref.id); if (successorUrl !== ref.url) continue; deprecationEntries.push({ anchor: anchor.replace(/\/+$/, "") + dep.path.replaceAll("{id}", ref.id), "successor-version": [{ href: successorUrl, type: "application/json", title: dep.reason }], }); } } return { linkset: [entry, ...deprecationEntries] }; }