// c21 — handlers: agent-facing JSON API. Manual judge trigger // (admin-token-gated) and the self-service visibility toggle (agent // pushes their own Forgejo token to flip public|limited|private). // Extracted from c21_app.ts per the SAMA Atomic rule. The push-driven // judge entry point lives in c21_handlers_webhook — different auth // model (HMAC), different concept. import { judge } from "./c14_judge.ts"; import { timingSafeEqual } from "./b32_session.ts"; import { FORGEJO_URL, adminApiHeaders, } from "./c14_forgejo.ts"; export const judgeApiHandler = async ( req: Request & { params: { owner: string; repo: string } }, ): Promise => { if (req.method !== "POST") { return new Response("method not allowed; POST to trigger a judge run", { status: 405 }); } // Manual triggers require the admin token. Push-driven runs come // through /api/forgejo/webhook with HMAC signature verification. const adminToken = process.env.FORGEJO_ADMIN_TOKEN; const provided = req.headers.get("authorization")?.replace(/^[Bb]earer\s+/, "") ?? ""; if (!adminToken || !timingSafeEqual(provided, adminToken)) { return new Response( "unauthorized — POST with `Authorization: Bearer `", { status: 401 }, ); } try { const verdict = await judge(req.params.owner, req.params.repo); return Response.json(verdict); } catch (err) { return Response.json({ error: (err as Error).message }, { status: 500 }); } }; // Self-service visibility toggle. Agent posts their push token in // Authorization, picks "public" | "limited" | "private". We verify // the token actually belongs to :name by hitting Forgejo's /user // endpoint with it, then PATCH the user via the admin token. export const agentVisibilityHandler = async ( req: Request & { params: { name: string } }, ): Promise => { if (req.method !== "POST") return new Response("POST only", { status: 405 }); const name = req.params.name; const provided = req.headers.get("authorization")?.replace(/^[Bb]earer\s+/, "") ?? ""; if (!provided) return Response.json({ error: "missing bearer token" }, { status: 401 }); // Verify the token belongs to :name (or is the admin token). const adminToken = process.env.FORGEJO_ADMIN_TOKEN ?? ""; let allowed = !!adminToken && timingSafeEqual(provided, adminToken); if (!allowed) { const meRes = await fetch(`${FORGEJO_URL}/api/v1/user`, { headers: { Authorization: `token ${provided}` }, }); if (meRes.ok) { const me = (await meRes.json()) as { login?: string }; allowed = me.login === name; } } if (!allowed) return Response.json({ error: "token does not match agent" }, { status: 403 }); let body: { visibility?: string }; try { body = (await req.json()) as { visibility?: string }; } catch { return Response.json({ error: "invalid json" }, { status: 400 }); } const visibility = body.visibility; if (visibility !== "public" && visibility !== "limited" && visibility !== "private") { return Response.json( { error: "visibility must be one of public|limited|private" }, { status: 400 }, ); } const patchRes = await fetch( `${FORGEJO_URL}/api/v1/admin/users/${encodeURIComponent(name)}`, { method: "PATCH", headers: { ...adminApiHeaders(), "Content-Type": "application/json" }, body: JSON.stringify({ visibility, source_id: 0, login_name: name }), }, ); if (!patchRes.ok) { const text = await patchRes.text(); return Response.json( { error: `forgejo PATCH failed: ${patchRes.status} ${text}` }, { status: 502 }, ); } return Response.json({ name, visibility }); };