// test-adjacency: exempt — imports astro:env/server, not loadable in vitest
/*
RFC-0290: the only astro-aware module in this package. Wires createAgentGate
into ready-to-export Astro APIRoutes: reads the QStash publish secret via
astro:env/server (the same substrate the send-message section already uses,
RFC-0181), and reads knowledge files by self-fetching the site's own static
public/ assets (works identically in dev and on Cloudflare Workers).
- Do not implement gate logic here — that is index.ts/mcp/*; this module
only wires ports and exposes Astro's GET/POST shape.
- RFC-1112/1113/1114 gap fix: CORS Allow-Headers gains Idempotency-Key, MCP-Protocol-Version, A2A-Version; Expose-Headers gains X-Agent-Idempotency.
- RFC-1172: closed agent.traffic telemetry — ports.telemetry sink, TrafficRecorder + boundary emit via withTrafficEmit
- RFC-1172: fo-review — extractAgentIdentity on all routes, honest mcpProtocolVersion, top-level import, test header
- RFC-1187: phases 1-3 — contracts, Redis registry, AS core + Bearer pipeline
- RFC-1187: fo-review — 503+Retry-After auth-backend-unavailable, streamed 64KiB JWKS cap, keyVersion-fed kid
RFC-0290, RFC-0954, RFC-1112, RFC-1114, RFC-1115
*/
import type { APIRoute } from "astro";
import {
UPSTASH_QSTASH_TOKEN,
UPSTASH_REDIS_REST_URL,
UPSTASH_REDIS_REST_TOKEN,
getSecret,
} from "astro:env/server";
import { buildQstashPublish, restRedisReceiptStore } from "@warpgogol/werkstatt-shared/integration";
import { restRedisAgentRegistry } from "@warpgogol/werkstatt-shared/integration/agent-registry";
import type { AgentSurfaceManifest } from "@warpgogol/werkstatt-shared/agent";
import {
b64uDecode,
b64uEncode,
deriveAgentId,
buildAgentProblem,
type AgentRegistrationVerifier,
} from "@warpgogol/werkstatt-shared/agent";
import { getPublicKey, verifyBytes } from "@warpgogol/werkstatt-shared/signing";
import { createHash } from "node:crypto";
import {
runOAuthChallenge,
runAgentRegister,
runAgentToken,
runAgentJwks,
runWhoami,
runRotate,
readBoundedBodyText,
type AgentAuthPorts,
} from "./agent-auth.ts";
import {
SEARCH_EMBEDDING_MODEL,
SEARCH_MAX_TOP_K,
SEARCH_DEFAULT_TOP_K,
SEARCH_CHUNK_TEXT_MAX_LENGTH,
SEARCH_MANIFEST_PATH,
type SearchQuery,
type SearchChunk,
type SearchManifest,
} from "@warpgogol/werkstatt-shared/agent";
import type { CapabilityRecord } from "@warpgogol/werkstatt-shared/ontology";
import { createAgentGate, extractAgentIdentity, type AgentGatePorts } from "./index.ts";
import { emitAgentTraffic, workerLogTelemetry, type TrafficRecorder } from "./telemetry.ts";
import { workerLogUsage } from "./usage.ts";
import { workerLogPresentationVerdict } from "./presentation.ts";
import { createFixedWindowLimiter } from "./limits.ts";
import { runAgentSearch } from "./search-route.ts";
import { runAgentHealth } from "./health-route.ts";
import { runNlwebAsk } from "./nlweb-ask.ts";
const CORS_HEADERS: Record = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
// RFC-1112/1113/1114/1187: browser agents must be allowed to send the protocol
// headers — Idempotency-Key (actions), MCP-Protocol-Version (MCP), A2A-Version,
// Authorization (Bearer tokens for the registration tier).
"Access-Control-Allow-Headers":
"Content-Type, Signature-Agent, Signature, User-Agent, x-search-reindex-token, Idempotency-Key, MCP-Protocol-Version, A2A-Version, Authorization",
"Access-Control-Expose-Headers": "X-Agent-Idempotency",
"Access-Control-Max-Age": "86400",
};
function withCors(response: Response): Response {
for (const [key, value] of Object.entries(CORS_HEADERS)) {
response.headers.set(key, value);
}
return response;
}
/**
* RFC-1187: decode the SIGNING_PRIVATE_KEY secret into an Ed25519 seed.
* Accepts the repo's two encodings: PEM wrapping the raw 32-byte seed
* (toPem output) or a standard PKCS8 DER (48 bytes — seed is the tail),
* plus 64-char hex.
*/
function parseSigningSecret(value: string): Uint8Array | null {
const trimmed = value.trim();
if (trimmed.includes("-----BEGIN")) {
const b64 = trimmed
.replace(/-----BEGIN [A-Z ]+-----/g, "")
.replace(/-----END [A-Z ]+-----/g, "")
.replace(/\s+/g, "");
const bytes = new Uint8Array(Buffer.from(b64, "base64"));
if (bytes.length === 32) return bytes;
if (bytes.length === 48) return bytes.slice(-32);
return null;
}
if (/^[0-9a-fA-F]{64}$/.test(trimmed)) {
return new Uint8Array(Buffer.from(trimmed, "hex"));
}
return null;
}
/**
* RFC-1187: concrete key-proof verifier — the ONLY place the signatureAgent
* JWKS fetch happens (SSRF guards live here: https-only, redirect error,
* 3s timeout, 64 KiB body cap). Tries every Ed25519 OKP key in the fetched
* directory (kid narrows when present); the derived agentId binds to the
* signatureAgent URL, not to any caller-chosen handle.
*/
function createAgentRegistrationVerifier(): AgentRegistrationVerifier {
return {
async verify(proof) {
try {
const url = new URL(proof.signatureAgent);
if (url.protocol !== "https:") return null;
const res = await fetch(url, {
redirect: "error",
signal: AbortSignal.timeout(3000),
headers: { accept: "application/json" },
});
if (!res.ok) return null;
// 64 KiB cap enforced WHILE reading — a hostile directory must not
// stream unbounded bytes into the isolate before the check.
const contentLength = Number(res.headers.get("content-length") ?? "0");
if (contentLength > 64 * 1024) return null;
const text = await readBoundedBodyText(res, 64 * 1024);
if (text === null) return null;
const doc = JSON.parse(text) as {
keys?: Array<{ kty?: string; crv?: string; x?: string; kid?: string }>;
};
const candidates = (doc.keys ?? []).filter(
(key) =>
key.kty === "OKP" &&
key.crv === "Ed25519" &&
typeof key.x === "string" &&
(!proof.kid || key.kid === proof.kid),
);
const signature = b64uDecode(proof.signature);
if (!signature || signature.length !== 64) return null;
const message = new TextEncoder().encode(proof.challenge);
for (const key of candidates) {
const publicKey = b64uDecode(key.x!);
if (publicKey?.length === 32 && (await verifyBytes(publicKey, message, signature))) {
return {
agentId: deriveAgentId(proof.signatureAgent, (input) =>
createHash("sha256").update(input, "utf8").digest("hex"),
),
};
}
}
return null;
} catch {
return null;
}
},
};
}
/**
* RFC-1187: assemble the authorization-server ports when the runtime wiring
* exists — Upstash Redis (registry) + SIGNING_PRIVATE_KEY (token issuance,
* the same passport key). Absent either → anonymous tier everywhere.
*/
async function buildAuthPorts(manifest: AgentSurfaceManifest): Promise {
if (!UPSTASH_REDIS_REST_URL || !UPSTASH_REDIS_REST_TOKEN) return null;
const secret = getSecret("SIGNING_PRIVATE_KEY");
if (!secret) return null;
const privateKey = parseSigningSecret(secret);
if (!privateKey) {
console.warn("agent-auth: SIGNING_PRIVATE_KEY is set but unreadable — auth ports disabled");
return null;
}
const publicKey = await getPublicKey(privateKey);
return {
registry: restRedisAgentRegistry({
url: UPSTASH_REDIS_REST_URL,
token: UPSTASH_REDIS_REST_TOKEN,
}),
verifier: createAgentRegistrationVerifier(),
tokenPrivateKey: privateKey,
tokenPublicKey: publicKey,
// interfaces.oauth.keyVersion is stamped at generation from the active
// passport key; "v1" is the schema default when the field is absent.
keyVersion: manifest.interfaces?.oauth?.keyVersion ?? "v1",
issuer: manifest.baseUrl.replace(/\/+$/, ""),
resourceMetadataUrl: `${manifest.baseUrl.replace(/\/+$/, "")}/.well-known/oauth-protected-resource`,
};
}
async function buildPorts(
manifest: AgentSurfaceManifest,
request: Request,
): Promise {
const authPorts = await buildAuthPorts(manifest);
return {
knowledge: {
async read(path: string): Promise {
try {
const res = await fetch(new URL(path, request.url));
return res.ok ? await res.text() : null;
} catch {
return null;
}
},
},
dispatch: {
async send(event) {
const publishRequest = buildQstashPublish(event, {
token: UPSTASH_QSTASH_TOKEN ?? "",
callbackUrl: `${manifest.baseUrl}/api/integration-route`,
});
const res = await fetch(publishRequest);
if (!res.ok) throw new Error(`QStash publish responded ${res.status}`);
// RFC-1112: the adapter stamps submittedAt at publish time; receiptId == eventId.
return {
receiptId: event.eventId,
status: "accepted" as const,
duplicate: false,
submittedAt: new Date().toISOString(),
};
},
},
now: () => new Date(),
// RFC-1172: one structured JSON line per gate request → Workers logs.
telemetry: workerLogTelemetry,
// RFC-1212: journaled usage records — same stdout channel, drained by the
// site billing bridge (pull model — engine never calls billing packages).
usage: workerLogUsage,
// RFC-1213: journaled presentation verdicts — hash-only records, drained
// like usage. The presentationVerifier port stays unconfigured until a
// VC DM 2.0 library is selected (readiness — the boundary fails closed).
presentationJournal: workerLogPresentationVerdict,
// RFC-0291: 60-second fixed window per capability per IP by default;
// RFC-1212 metering free-allowance windows pass day/month-scale seconds.
createRateLimiter: (maxPerWindow, windowSeconds = 60) =>
createFixedWindowLimiter(windowSeconds, maxPerWindow),
// RFC-1112: receipt store over the same EU Redis as the delivery ledger.
// Absent when the secrets are not configured — the gate then answers with
// X-Agent-Idempotency: disabled.
...(UPSTASH_REDIS_REST_URL && UPSTASH_REDIS_REST_TOKEN
? {
idempotency: restRedisReceiptStore({
url: UPSTASH_REDIS_REST_URL,
token: UPSTASH_REDIS_REST_TOKEN,
}),
}
: {}),
// RFC-1187: auth ports ride the same request-scoped build — null when the
// site has no signing key wired (anonymous tier stays the contract).
...(authPorts ? { auth: authPorts } : {}),
};
}
/**
* RFC-1172: boundary emit — build ports, run the handler with a fresh
* TrafficRecorder, emit exactly one closed event after the response resolves.
*/
async function withTrafficEmit(
manifest: AgentSurfaceManifest,
request: Request,
protocol: "http" | "mcp" | "a2a" | "nlweb",
run: (traffic: TrafficRecorder, ports: AgentGatePorts) => Promise,
): Promise {
const ports = await buildPorts(manifest, request);
const traffic: TrafficRecorder = {};
const started = Date.now();
const response = await run(traffic, ports);
await emitAgentTraffic(ports, {
systemId: manifest.site,
protocol,
response,
traffic,
durationMs: Date.now() - started,
});
return response;
}
/** Factory the generated apps//src/pages/api/agent/mcp.ts calls with its manifest + capabilities. */
export function createAgentMcpRoute(
manifest: AgentSurfaceManifest,
catalog: CapabilityRecord[],
): { GET: APIRoute; POST: APIRoute; OPTIONS: APIRoute } {
return {
GET: async ({ request }) =>
withCors(
await withTrafficEmit(manifest, request, "mcp", (traffic, ports) =>
createAgentGate(manifest, catalog, ports).handleMcp(request, traffic),
),
),
POST: async ({ request }) =>
withCors(
await withTrafficEmit(manifest, request, "mcp", (traffic, ports) =>
createAgentGate(manifest, catalog, ports).handleMcp(request, traffic),
),
),
OPTIONS: async () => new Response(null, { status: 204, headers: CORS_HEADERS }),
};
}
/** Factory the generated apps//src/pages/api/agent/a2a.ts calls (RFC-1114). */
export function createAgentA2aRoute(
manifest: AgentSurfaceManifest,
catalog: CapabilityRecord[],
): { GET: APIRoute; POST: APIRoute; OPTIONS: APIRoute } {
return {
GET: async ({ request }) =>
withCors(
await withTrafficEmit(manifest, request, "a2a", (traffic, ports) =>
createAgentGate(manifest, catalog, ports).handleA2a(request, traffic),
),
),
POST: async ({ request }) =>
withCors(
await withTrafficEmit(manifest, request, "a2a", (traffic, ports) =>
createAgentGate(manifest, catalog, ports).handleA2a(request, traffic),
),
),
OPTIONS: async () => new Response(null, { status: 204, headers: CORS_HEADERS }),
};
}
/** Factory the generated apps//src/pages/api/agent/actions/[id].ts calls. */
export function createAgentActionRoute(
manifest: AgentSurfaceManifest,
catalog: CapabilityRecord[],
): { POST: APIRoute; OPTIONS: APIRoute } {
return {
POST: async ({ request, params }) =>
withCors(
await withTrafficEmit(manifest, request, "http", (traffic, ports) =>
createAgentGate(manifest, catalog, ports).handleAction(
String(params.id ?? ""),
request,
traffic,
),
),
),
OPTIONS: async () => new Response(null, { status: 204, headers: CORS_HEADERS }),
};
}
// ---------------------------------------------------------------------------
// RFC-0954: Semantic search route
// ---------------------------------------------------------------------------
/** Cloudflare Workers AI + Vectorize bindings available at runtime. */
interface SearchEnv {
AI?: { run(model: string, options: { text: string[] }): Promise<{ data: number[][] }> };
SEARCH_INDEX?: {
query(
vector: number[],
options: { topK: number; returnMetadata?: "all" | "none" },
): Promise<{
matches?: Array<{ id: string; score: number; metadata?: Record }>;
}>;
upsert(
vectors: Array<{ id: string; values: number[]; metadata: Record }>,
): Promise;
};
ASSETS?: { fetch(request: Request): Promise };
}
/** Max chunks per Workers AI embedding batch call. */
const EMBED_BATCH_SIZE = 100;
/** Max vectors per Vectorize upsert batch. */
const UPSERT_BATCH_SIZE = 100;
class SearchValidationError extends Error {}
const VALID_CHUNK_TYPES: readonly string[] = ["page", "knowledge", "faq", "prose"];
/** Parse SearchQuery from URL search params. */
function parseSearchQuery(url: URL): SearchQuery {
const q = url.searchParams.get("q") ?? "";
if (!q.trim()) {
throw new SearchValidationError("Missing required query parameter 'q'");
}
if (q.length > 1000) {
throw new SearchValidationError("Query parameter 'q' exceeds 1000 characters");
}
const topKRaw = url.searchParams.get("topK");
let topK = SEARCH_DEFAULT_TOP_K;
if (topKRaw) {
const parsed = Number.parseInt(topKRaw, 10);
if (Number.isNaN(parsed) || parsed < 1) {
topK = SEARCH_DEFAULT_TOP_K;
} else {
topK = Math.min(parsed, SEARCH_MAX_TOP_K);
}
}
const typeRaw = url.searchParams.get("type");
if (typeRaw && !VALID_CHUNK_TYPES.includes(typeRaw)) {
throw new SearchValidationError(
`Invalid 'type' parameter — must be one of: ${VALID_CHUNK_TYPES.join(", ")}`,
);
}
return {
q,
...(url.searchParams.get("lang") ? { lang: url.searchParams.get("lang")! } : {}),
...(typeRaw ? { type: typeRaw as SearchChunk["type"] } : {}),
topK,
};
}
/** Resolve Cloudflare Workers env bindings via dynamic import (CF-IMPORT-01 compliant). */
async function resolveSearchEnv(): Promise {
try {
const { env } = await import("cloudflare:workers");
return env as unknown as SearchEnv;
} catch {
return {};
}
}
/** Fetch the static search manifest from the site's own assets. */
async function fetchSearchManifest(
request: Request,
env: SearchEnv,
): Promise {
try {
const manifestUrl = new URL(`/${SEARCH_MANIFEST_PATH}`, request.url);
const res = env.ASSETS
? await env.ASSETS.fetch(new Request(manifestUrl))
: await fetch(manifestUrl);
if (!res.ok) return null;
return (await res.json()) as SearchManifest;
} catch {
return null;
}
}
/** Factory the generated apps//src/pages/api/agent/search.ts calls. */
export function createAgentSearchRoute(manifest: AgentSurfaceManifest): {
GET: APIRoute;
POST: APIRoute;
OPTIONS: APIRoute;
} {
return {
GET: async ({ request }) =>
await withTrafficEmit(manifest, request, "http", async (traffic) => {
traffic.operation = "search";
traffic.observedIdentity = extractAgentIdentity(request);
const env = await resolveSearchEnv();
if (!env.AI || !env.SEARCH_INDEX) {
return withCors(
new Response(
JSON.stringify({
error: "Search index not available — AI or SEARCH_INDEX binding missing",
}),
{ status: 503, headers: { "Content-Type": "application/json" } },
),
);
}
let query: SearchQuery;
try {
query = parseSearchQuery(new URL(request.url));
} catch (err) {
return withCors(
new Response(JSON.stringify({ error: (err as Error).message }), {
status: 400,
headers: { "Content-Type": "application/json" },
}),
);
}
const searchManifest = await fetchSearchManifest(request, env);
if (!searchManifest) {
return withCors(
new Response(JSON.stringify({ error: "Search manifest not found" }), {
status: 503,
headers: { "Content-Type": "application/json" },
}),
);
}
try {
// RFC-1115: core lives in search-route.ts — flattened citation shape,
// measured tookMs, no astro imports (vitest-loadable).
const response = await runAgentSearch(query, searchManifest, {
ai: env.AI,
searchIndex: env.SEARCH_INDEX,
});
return withCors(
new Response(JSON.stringify(response), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
} catch (err) {
return withCors(
new Response(JSON.stringify({ error: `Search failed: ${(err as Error).message}` }), {
status: 502,
headers: { "Content-Type": "application/json" },
}),
);
}
}),
POST: async ({ request }) =>
await withTrafficEmit(manifest, request, "http", async (traffic) => {
const env = await resolveSearchEnv();
const url = new URL(request.url);
traffic.observedIdentity = extractAgentIdentity(request);
// RFC-1172: reindex is operator maintenance, not a query — `other`.
traffic.operation = url.pathname.endsWith("/reindex") ? "other" : "search";
if (traffic.operation === "other") traffic.capability = "search.reindex";
// Reindex endpoint — requires x-search-reindex-token header when configured
if (url.pathname.endsWith("/reindex")) {
const reindexToken = process.env.SEARCH_REINDEX_TOKEN;
if (reindexToken) {
const provided = request.headers.get("x-search-reindex-token");
if (provided !== reindexToken) {
return withCors(
new Response(JSON.stringify({ error: "Invalid reindex token" }), {
status: 403,
headers: { "Content-Type": "application/json" },
}),
);
}
}
if (!env.AI || !env.SEARCH_INDEX) {
return withCors(
new Response(
JSON.stringify({
error: "Search index not available — AI or SEARCH_INDEX binding missing",
}),
{ status: 503, headers: { "Content-Type": "application/json" } },
),
);
}
const searchManifest = await fetchSearchManifest(request, env);
if (!searchManifest) {
return withCors(
new Response(JSON.stringify({ error: "Search manifest not found" }), {
status: 503,
headers: { "Content-Type": "application/json" },
}),
);
}
try {
const chunks = searchManifest.chunks;
let upserted = 0;
for (let i = 0; i < chunks.length; i += EMBED_BATCH_SIZE) {
const batch = chunks.slice(i, i + EMBED_BATCH_SIZE);
const texts = batch.map((c) => c.text.slice(0, SEARCH_CHUNK_TEXT_MAX_LENGTH));
const embedResult = await env.AI.run(SEARCH_EMBEDDING_MODEL, { text: texts });
const vectors = embedResult.data;
for (let j = 0; j < batch.length; j += UPSERT_BATCH_SIZE) {
const upsertBatch: Array<{
id: string;
values: number[];
metadata: Record;
}> = [];
for (let k = j; k < Math.min(j + UPSERT_BATCH_SIZE, batch.length); k++) {
upsertBatch.push({
id: batch[k].id,
values: vectors[k],
metadata: {
lang: batch[k].lang,
type: batch[k].type,
url: batch[k].url,
heading: batch[k].heading ?? "",
},
});
}
await env.SEARCH_INDEX.upsert(upsertBatch);
upserted += upsertBatch.length;
}
}
return withCors(
new Response(
JSON.stringify({
status: "ok",
mutationId: `reindex-${Date.now()}`,
chunks: upserted,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);
} catch (err) {
return withCors(
new Response(JSON.stringify({ error: `Reindex failed: ${(err as Error).message}` }), {
status: 500,
headers: { "Content-Type": "application/json" },
}),
);
}
}
// Non-reindex POST — direct users to GET for queries
return withCors(
new Response(
JSON.stringify({
error: "Use GET for search queries, POST /reindex for reindexing",
}),
{
status: 405,
headers: { "Content-Type": "application/json", Allow: "GET, OPTIONS" },
},
),
);
}),
OPTIONS: async () => new Response(null, { status: 204, headers: CORS_HEADERS }),
};
}
// ---------------------------------------------------------------------------
// RFC-1165: NLWeb /ask route
// ---------------------------------------------------------------------------
/**
* Factory the generated apps//src/pages/ask.ts calls.
* Core lives in nlweb-ask.ts (vitest-loadable, deterministic extractive —
* ADR-0097); this adapter only resolves env bindings, the search manifest,
* and the RFC-1164 .jsonld mirror fetch, then maps { status, body } to HTTP.
*/
export function createAgentNlwebRoute(manifest: AgentSurfaceManifest): {
POST: APIRoute;
OPTIONS: APIRoute;
} {
return {
POST: async ({ request }) =>
await withTrafficEmit(manifest, request, "nlweb", async (traffic) => {
traffic.operation = "ask";
traffic.observedIdentity = extractAgentIdentity(request);
const env = await resolveSearchEnv();
if (!env.AI || !env.SEARCH_INDEX) {
return withCors(
new Response(
JSON.stringify({
error: "Search index not available — AI or SEARCH_INDEX binding missing",
}),
{ status: 503, headers: { "Content-Type": "application/json" } },
),
);
}
const searchManifest = await fetchSearchManifest(request, env);
if (!searchManifest) {
return withCors(
new Response(JSON.stringify({ error: "Search manifest not found" }), {
status: 503,
headers: { "Content-Type": "application/json" },
}),
);
}
let body: unknown;
try {
body = await request.json();
} catch {
return withCors(
new Response(JSON.stringify({ error: "Request body must be JSON" }), {
status: 400,
headers: { "Content-Type": "application/json" },
}),
);
}
// RFC-1164: schema_object per result is sourced from the route's .jsonld
// mirror — served sibling of the search manifest, fetched via ASSETS.
const fetchMirror = async (mirrorPath: string): Promise => {
try {
const url = new URL(`/${mirrorPath}`, request.url);
const res = env.ASSETS ? await env.ASSETS.fetch(new Request(url)) : await fetch(url);
return res.ok ? await res.json() : null;
} catch {
return null;
}
};
try {
const { status, body: responseBody } = await runNlwebAsk(body, manifest, searchManifest, {
ai: env.AI,
searchIndex: env.SEARCH_INDEX,
fetchMirror,
});
return withCors(
new Response(JSON.stringify(responseBody), {
status,
headers: { "Content-Type": "application/json" },
}),
);
} catch (err) {
return withCors(
new Response(JSON.stringify({ error: `Ask failed: ${(err as Error).message}` }), {
status: 502,
headers: { "Content-Type": "application/json" },
}),
);
}
}),
OPTIONS: async () => new Response(null, { status: 204, headers: CORS_HEADERS }),
};
}
// ---------------------------------------------------------------------------
// RFC-1116: Agent surface health route
// ---------------------------------------------------------------------------
/**
* Factory the generated apps//src/pages/api/agent/health.ts calls.
* Core lives in health-route.ts (vitest-loadable); this adapter only resolves
* env + the QStash token and wraps the response in CORS headers.
*/
export function createAgentHealthRoute(manifest: AgentSurfaceManifest): {
GET: APIRoute;
OPTIONS: APIRoute;
} {
return {
GET: async ({ request }) =>
await withTrafficEmit(manifest, request, "http", async (traffic) => {
traffic.operation = "health";
traffic.observedIdentity = extractAgentIdentity(request);
const env = await resolveSearchEnv();
const { status, body } = await runAgentHealth(manifest, {
requestUrl: request.url,
fetchFn: fetch,
env,
qstashToken: UPSTASH_QSTASH_TOKEN,
});
return withCors(
new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
}),
);
}),
OPTIONS: async () => new Response(null, { status: 204, headers: CORS_HEADERS }),
};
}
// ---------------------------------------------------------------------------
// RFC-1187: authorization-server route adapters — thin wrappers over the pure
// agent-auth.ts core. A missing `ports.auth` (no Redis secrets / no signing
// key) answers oauth-unavailable rather than 500: the anonymous contract
// stays the fallback everywhere.
// ---------------------------------------------------------------------------
const OAUTH_UNAVAILABLE = () =>
new Response(JSON.stringify(buildAgentProblem("oauth-unavailable")), {
status: 404,
headers: { "content-type": "application/problem+json" },
});
// RFC-1187 §196/§258: a registry outage on the AS endpoints themselves is a
// retryable 503 + Retry-After — never a bare Workers 500 page.
const AUTH_BACKEND_UNAVAILABLE = () =>
new Response(
JSON.stringify(
buildAgentProblem("auth-backend-unavailable", {
detail: "Authorization backend unavailable — retry shortly.",
}),
),
{
status: 503,
headers: { "content-type": "application/problem+json", "Retry-After": "30" },
},
);
function oauthRoute(
manifest: AgentSurfaceManifest,
operation: string,
methods: string[],
handler: (request: Request, auth: AgentAuthPorts) => Promise | Response,
): Record {
const run: APIRoute = async ({ request }) =>
withCors(
await withTrafficEmit(manifest, request, "http", async (traffic, ports) => {
traffic.operation = "other";
traffic.capability = operation;
traffic.observedIdentity = extractAgentIdentity(request);
if (!ports.auth) return OAUTH_UNAVAILABLE();
try {
return await handler(request, ports.auth);
} catch (err) {
// Registry/Redis outage on the stateful endpoints — retryable problem,
// never a bare Workers 500 page.
console.warn(
`agent-auth: ${operation} backend failure: ${err instanceof Error ? err.message : String(err)}`,
);
return AUTH_BACKEND_UNAVAILABLE();
}
}),
);
const routes: Record = {
OPTIONS: async () => new Response(null, { status: 204, headers: CORS_HEADERS }),
};
for (const method of methods) routes[method] = run;
return routes;
}
/** POST /api/agent/oauth/challenge — single-use registration nonce. */
export function createAgentOAuthChallengeRoute(
manifest: AgentSurfaceManifest,
): Record {
return oauthRoute(manifest, "oauth.challenge", ["POST"], (_req, auth) => runOAuthChallenge(auth));
}
/** POST /api/agent/oauth/token — client_credentials → EdDSA JWT. */
export function createAgentOAuthTokenRoute(
manifest: AgentSurfaceManifest,
): Record {
return oauthRoute(manifest, "oauth.token", ["POST"], (req, auth) => runAgentToken(req, auth));
}
/** GET /api/agent/oauth/jwks.json — the active passport key as JWKS (kid follows rotation). */
export function createAgentOAuthJwksRoute(
manifest: AgentSurfaceManifest,
): Record {
return oauthRoute(manifest, "oauth.jwks", ["GET"], (_req, auth) =>
runAgentJwks({
kty: "OKP",
crv: "Ed25519",
x: b64uEncode(auth.tokenPublicKey),
kid: auth.keyVersion,
alg: "EdDSA",
use: "sig",
} as JsonWebKey),
);
}
/** POST /api/agent/v1/registration — WBA key-proof registration. */
export function createAgentRegistrationRoute(
manifest: AgentSurfaceManifest,
): Record {
return oauthRoute(manifest, "registration.register", ["POST"], (req, auth) =>
runAgentRegister(req, auth),
);
}
/** GET /api/agent/v1/registration/whoami — Bearer-required self-report. */
export function createAgentWhoamiRoute(manifest: AgentSurfaceManifest): Record {
return oauthRoute(manifest, "registration.whoami", ["GET"], (req, auth) => runWhoami(req, auth));
}
/** POST /api/agent/v1/registration/rotate — Bearer-required credential rotation. */
export function createAgentRotateRoute(manifest: AgentSurfaceManifest): Record {
return oauthRoute(manifest, "registration.rotate", ["POST"], (req, auth) => runRotate(req, auth));
}