// 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { // 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 => { 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 => { 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 => { 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, }); };