syntaxai/tdd.md · main · src / c14_forgejo.ts
// 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,
});
};