syntaxai/tdd.md · main · src / d21_handlers_api_agents.ts

d21_handlers_api_agents.ts 96 lines · 3698 bytes raw
// 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<Response> => {
  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 <admin-token>`",
      { 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<Response> => {
  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 });
};