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

c14_forgejo.ts 354 lines · 11805 bytes raw
// c14 — secondary I/O: HTTP client to the local Forgejo instance. Owns
// every URL reachable at git.tdd.md (admin API, user repos, raw git
// protocol, webhook setup) plus the proxy that forwards git-protocol
// requests through tdd.md to keep the public hostname uniform.

// Internal URL — Bun container talks to Forgejo via host.containers.internal
// (rootless podman's standard hostname for the host network). Falls back to
// the public URL for local dev.
export const FORGEJO_URL = process.env.FORGEJO_URL ?? "https://git.tdd.md";
const ADMIN_TOKEN = process.env.FORGEJO_ADMIN_TOKEN ?? "";

const adminAuth = (): HeadersInit => ({
  Authorization: `token ${ADMIN_TOKEN}`,
});

const userAuth = (username: string, password: string): HeadersInit => ({
  Authorization: `Basic ${btoa(`${username}:${password}`)}`,
});

export const isConfigured = (): boolean => ADMIN_TOKEN !== "";

export const userExists = async (username: string): Promise<boolean> => {
  const res = await fetch(`${FORGEJO_URL}/api/v1/users/${encodeURIComponent(username)}`, {
    headers: adminAuth(),
  });
  return res.status === 200;
};

export const createUser = async (params: {
  username: string;
  email: string;
  password: string;
  fullName?: string;
}): Promise<void> => {
  const res = await fetch(`${FORGEJO_URL}/api/v1/admin/users`, {
    method: "POST",
    headers: { ...adminAuth(), "Content-Type": "application/json" },
    body: JSON.stringify({
      username: params.username,
      email: params.email,
      password: params.password,
      full_name: params.fullName ?? params.username,
      must_change_password: false,
      send_notify: false,
    }),
  });
  if (!res.ok) {
    const text = await res.text();
    throw new Error(`forgejo createUser ${res.status}: ${text}`);
  }
};

export const setUserPassword = async (username: string, password: string): Promise<void> => {
  const res = await fetch(`${FORGEJO_URL}/api/v1/admin/users/${encodeURIComponent(username)}`, {
    method: "PATCH",
    headers: { ...adminAuth(), "Content-Type": "application/json" },
    body: JSON.stringify({
      password,
      must_change_password: false,
      source_id: 0,
      login_name: username,
    }),
  });
  if (!res.ok) {
    const text = await res.text();
    throw new Error(`forgejo setUserPassword ${res.status}: ${text}`);
  }
};

export const repoExists = async (owner: string, repo: string): Promise<boolean> => {
  const res = await fetch(`${FORGEJO_URL}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, {
    headers: adminAuth(),
  });
  return res.status === 200;
};

// Creates a per-repo webhook that fires on push events. The webhook
// posts to /api/forgejo/webhook on tdd.md, signed with WEBHOOK_SECRET so
// our endpoint can verify it. Idempotent — checks for an existing hook
// with the same URL before creating.
export const ensureRepoWebhook = async (params: {
  owner: string;
  repo: string;
  webhookUrl: string;
  secret: string;
}): Promise<void> => {
  const base = `${FORGEJO_URL}/api/v1/repos/${encodeURIComponent(params.owner)}/${encodeURIComponent(params.repo)}/hooks`;
  const listRes = await fetch(base, { headers: adminAuth() });
  if (listRes.ok) {
    const hooks = (await listRes.json()) as { id: number; config: { url?: string } }[];
    const exists = hooks.some((h) => h.config?.url === params.webhookUrl);
    if (exists) return;
  }
  const res = await fetch(base, {
    method: "POST",
    headers: { ...adminAuth(), "Content-Type": "application/json" },
    body: JSON.stringify({
      type: "forgejo",
      active: true,
      events: ["push"],
      config: {
        url: params.webhookUrl,
        content_type: "json",
        secret: params.secret,
      },
    }),
  });
  if (!res.ok) {
    const text = await res.text();
    throw new Error(`forgejo ensureRepoWebhook ${res.status}: ${text}`);
  }
};

export const createRepoForUser = async (params: {
  username: string;
  name: string;
  description?: string;
}): Promise<void> => {
  const res = await fetch(`${FORGEJO_URL}/api/v1/admin/users/${encodeURIComponent(params.username)}/repos`, {
    method: "POST",
    headers: { ...adminAuth(), "Content-Type": "application/json" },
    body: JSON.stringify({
      name: params.name,
      description: params.description ?? "",
      // Private by default — the source is the agent's, not ours to
      // publish. Verdicts still render on tdd.md via admin-mediated
      // API calls; clones require the agent's push token.
      private: true,
      // No auto_init: the agent's first push becomes the genuine initial
      // commit. An admin-authored "Initial commit" would muddle the phase
      // log and break attribution on the agent's repo page.
      auto_init: false,
      default_branch: "main",
    }),
  });
  if (!res.ok) {
    const text = await res.text();
    throw new Error(`forgejo createRepo ${res.status}: ${text}`);
  }
};

interface TokenInfo {
  id: number;
  name: string;
}

const listTokens = async (username: string, password: string): Promise<TokenInfo[]> => {
  const res = await fetch(`${FORGEJO_URL}/api/v1/users/${encodeURIComponent(username)}/tokens`, {
    headers: userAuth(username, password),
  });
  if (!res.ok) return [];
  return (await res.json()) as TokenInfo[];
};

const deleteToken = async (username: string, password: string, tokenId: number): Promise<void> => {
  await fetch(`${FORGEJO_URL}/api/v1/users/${encodeURIComponent(username)}/tokens/${tokenId}`, {
    method: "DELETE",
    headers: userAuth(username, password),
  });
};

export const createPushToken = async (params: {
  username: string;
  password: string;
  name: string;
}): Promise<string> => {
  // Revoke any existing tokens with the same name so re-registration always
  // returns a fresh one and the previous one is invalidated.
  const existing = await listTokens(params.username, params.password);
  for (const t of existing) {
    if (t.name === params.name) {
      await deleteToken(params.username, params.password, t.id);
    }
  }

  const res = await fetch(`${FORGEJO_URL}/api/v1/users/${encodeURIComponent(params.username)}/tokens`, {
    method: "POST",
    headers: { ...userAuth(params.username, params.password), "Content-Type": "application/json" },
    body: JSON.stringify({
      name: params.name,
      // write:repository for the push; read:user so the agent can
      // verify their own identity against tdd.md's self-service
      // endpoints (e.g. POST /api/agents/:name/visibility).
      scopes: ["write:repository", "read:user"],
    }),
  });
  if (!res.ok) {
    const text = await res.text();
    throw new Error(`forgejo createPushToken ${res.status}: ${text}`);
  }
  const data = (await res.json()) as { sha1: string };
  return data.sha1;
};

const randomPassword = (): string =>
  Array.from(crypto.getRandomValues(new Uint8Array(32)))
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");

export interface AgentRegistration {
  username: string;
  pushToken: string;
  repoCloneUrl: string;
  isNew: boolean;
}

// Idempotent: if the user exists, reset their password and rotate the push
// token. Always also ensures the kata repo exists.
export const registerAgent = async (params: {
  username: string;
  email: string;
  fullName?: string;
  kata?: string;
}): Promise<AgentRegistration> => {
  const password = randomPassword();
  const isNew = !(await userExists(params.username));

  if (isNew) {
    await createUser({
      username: params.username,
      email: params.email,
      password,
      fullName: params.fullName,
    });
  } else {
    await setUserPassword(params.username, password);
  }

  const pushToken = await createPushToken({
    username: params.username,
    password,
    name: "tdd-md-push",
  });

  const kata = params.kata ?? "string-calc";
  if (!(await repoExists(params.username, kata))) {
    await createRepoForUser({
      username: params.username,
      name: kata,
      description: `${params.username}'s submission for the ${kata} kata`,
    });
  }

  const baseUrl = process.env.BASE_URL ?? "https://tdd.md";
  const webhookSecret = process.env.WEBHOOK_SECRET;
  if (webhookSecret) {
    try {
      await ensureRepoWebhook({
        owner: params.username,
        repo: kata,
        webhookUrl: `${baseUrl}/api/forgejo/webhook`,
        secret: webhookSecret,
      });
    } catch (err) {
      // Webhook is convenience; registration must still succeed without it.
      console.error(`webhook setup failed for ${params.username}/${kata}:`, err);
    }
  }

  return {
    username: params.username,
    pushToken,
    repoCloneUrl: `${baseUrl}/${params.username}/${kata}.git`,
    isNew,
  };
};

// Note: this file used to expose commitFile / getFileSha /
// getCommitDetail / getCommitDiff for syntaxai/tdd.md operations
// (admin web-edit + commit view). They were removed when c14_git
// took over those paths against the local bare repo. Forgejo no
// longer participates in the tdd.md repo's lifecycle — what's left
// in this file is for agent kata operations only (registerAgent,
// repo creation, webhook setup, and the admin proxy).

// ---------------------------------------------------------------------
// Read-side helpers used by c21 handlers + c51 rendering.
// ---------------------------------------------------------------------

export interface ForgejoUserSummary {
  id: number;
  login: string;
  is_admin?: boolean;
  // Forgejo visibility levels: "public" | "limited" | "private".
  // Anything other than "public" is hidden from anonymous tdd.md visitors.
  visibility?: string;
}

// Admin-token-authenticated headers for API calls. Agent repos are
// private by default; rendering the verdict page must still work. We
// proxy the data through the admin identity, never exposing the source
// or push protocol publicly.
export const adminApiHeaders = (): HeadersInit => {
  const token = process.env.FORGEJO_ADMIN_TOKEN;
  return token ? { Authorization: `token ${token}` } : {};
};

// Single-user visibility lookup for /:owner/:repo and /agents/:name.
// Returns the raw Forgejo string (or null if the user doesn't exist).
export const getUserVisibility = async (name: string): Promise<string | null> => {
  const r = await fetch(
    `${FORGEJO_URL}/api/v1/users/${encodeURIComponent(name)}`,
    { headers: adminApiHeaders() },
  );
  if (!r.ok) return null;
  const u = (await r.json()) as ForgejoUserSummary;
  return u.visibility ?? "public";
};

const HOP_BY_HOP = [
  "host",
  "connection",
  "keep-alive",
  "transfer-encoding",
  "upgrade",
  "proxy-authorization",
  "proxy-connection",
  "te",
  "trailer",
];

// Forward git protocol + Forgejo API/asset requests to Forgejo via the host
// network. Lets us serve everything under tdd.md (GitHub-style) without
// exposing git.tdd.md externally.
export const proxyToForgejo = async (req: Request, pathAndQuery: string): Promise<Response> => {
  const upstream = `${FORGEJO_URL}${pathAndQuery}`;
  const headers = new Headers(req.headers);
  for (const h of HOP_BY_HOP) headers.delete(h);
  headers.set("X-Forwarded-Host", "tdd.md");
  headers.set("X-Forwarded-Proto", "https");
  headers.set("X-Forwarded-For", req.headers.get("cf-connecting-ip") ?? "0.0.0.0");

  let body: ArrayBuffer | undefined;
  if (req.method !== "GET" && req.method !== "HEAD") {
    body = await req.arrayBuffer();
  }

  const upstreamRes = await fetch(upstream, {
    method: req.method,
    headers,
    body,
    redirect: "manual",
  });

  const responseHeaders = new Headers(upstreamRes.headers);
  for (const h of HOP_BY_HOP) responseHeaders.delete(h);

  return new Response(upstreamRes.body, {
    status: upstreamRes.status,
    statusText: upstreamRes.statusText,
    headers: responseHeaders,
  });
};