syntaxai/tdd.md · commit 77aebbe

Move git host to apex tdd.md, GitHub-style

Forgejo's ROOT_URL/DOMAIN/SSH_DOMAIN now point at tdd.md; the Bun server
catches paths the existing routes don't claim and either:

- proxies git smart-HTTP (paths containing .git, /info/refs?service=git-*,
  /git-upload-pack, /git-receive-pack) raw to Forgejo over the host
  network — clone, fetch, and push all flow through tdd.md
- renders a Bun-native repo overview for /:owner/:repo by hitting the
  Forgejo API for description and recent commits

Hop-by-hop headers are stripped on the way through. Agent registration's
clone URL is now derived from BASE_URL, so it ships as
https://tdd.md/<user>/<kata>.git. The git.tdd.md hostname keeps working
(Forgejo accepts any host) but is no longer linked from anywhere we
generate.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
author
syntaxai <[email protected]>
date
2026-05-03 16:41:10 +01:00
parent
6ba2976
commit
77aebbef89ead5ef0a366ff1ec26564bbe6156e5

3 files changed · +158 −5

modified scripts/p620/forgejo.container +7 −4
@@ -22,10 +22,13 @@ Environment=USER_UID=1000
2222 Environment=USER_GID=1000
2323
2424 # App URL en domain — Forgejo gebruikt deze voor link-generatie (clone URLs,
25-# emails, web links). Cloudflare termineert TLS, Forgejo zelf praat HTTP.
26-Environment=FORGEJO__server__ROOT_URL=https://git.tdd.md/
27-Environment=FORGEJO__server__DOMAIN=git.tdd.md
28-Environment=FORGEJO__server__SSH_DOMAIN=git.tdd.md
25+# emails, web links). Onze Bun-server op tdd.md proxiet git-protocol paths
26+# door naar Forgejo, dus de canonical URLs zijn tdd.md (geen git.tdd.md).
27+# git.tdd.md blijft technisch werken via cloudflare route maar wordt niet
28+# meer gelinkt vanuit Forgejo's UI/API.
29+Environment=FORGEJO__server__ROOT_URL=https://tdd.md/
30+Environment=FORGEJO__server__DOMAIN=tdd.md
31+Environment=FORGEJO__server__SSH_DOMAIN=tdd.md
2932 Environment=FORGEJO__server__HTTP_PORT=3000
3033
3134 # SQLite — geen aparte db container nodig.
modified src/forgejo.ts +2 −1
@@ -191,10 +191,11 @@ export const registerAgent = async (params: {
191191 });
192192 }
193193
194+ const baseUrl = process.env.BASE_URL ?? "https://tdd.md";
194195 return {
195196 username: params.username,
196197 pushToken,
197- repoCloneUrl: `https://git.tdd.md/${params.username}/${kata}.git`,
198+ repoCloneUrl: `${baseUrl}/${params.username}/${kata}.git`,
198199 isNew,
199200 };
200201 };
modified src/server.ts +149 −0
@@ -134,6 +134,141 @@ const timingSafeEqual = (a: string, b: string): boolean => {
134134 return r === 0;
135135 };
136136
137+// Forward git protocol + Forgejo API/asset requests to Forgejo via the host
138+// network. Lets us serve everything under tdd.md (GitHub-style) without
139+// exposing git.tdd.md externally.
140+const FORGEJO_INTERNAL = process.env.FORGEJO_URL ?? "https://git.tdd.md";
141+
142+const HOP_BY_HOP = [
143+ "host",
144+ "connection",
145+ "keep-alive",
146+ "transfer-encoding",
147+ "upgrade",
148+ "proxy-authorization",
149+ "proxy-connection",
150+ "te",
151+ "trailer",
152+];
153+
154+const proxyToForgejo = async (req: Request, pathAndQuery: string): Promise<Response> => {
155+ const upstream = `${FORGEJO_INTERNAL}${pathAndQuery}`;
156+ const headers = new Headers(req.headers);
157+ for (const h of HOP_BY_HOP) headers.delete(h);
158+ headers.set("X-Forwarded-Host", "tdd.md");
159+ headers.set("X-Forwarded-Proto", "https");
160+ headers.set("X-Forwarded-For", req.headers.get("cf-connecting-ip") ?? "0.0.0.0");
161+
162+ let body: ArrayBuffer | undefined;
163+ if (req.method !== "GET" && req.method !== "HEAD") {
164+ body = await req.arrayBuffer();
165+ }
166+
167+ const upstreamRes = await fetch(upstream, {
168+ method: req.method,
169+ headers,
170+ body,
171+ redirect: "manual",
172+ });
173+
174+ const responseHeaders = new Headers(upstreamRes.headers);
175+ for (const h of HOP_BY_HOP) responseHeaders.delete(h);
176+
177+ return new Response(upstreamRes.body, {
178+ status: upstreamRes.status,
179+ statusText: upstreamRes.statusText,
180+ headers: responseHeaders,
181+ });
182+};
183+
184+const isGitProtocol = (pathname: string, search: URLSearchParams): boolean => {
185+ if (pathname.includes(".git/") || pathname.endsWith(".git")) return true;
186+ if (
187+ pathname.endsWith("/info/refs") &&
188+ (search.get("service") === "git-upload-pack" || search.get("service") === "git-receive-pack")
189+ ) {
190+ return true;
191+ }
192+ if (pathname.endsWith("/git-upload-pack") || pathname.endsWith("/git-receive-pack")) {
193+ return true;
194+ }
195+ return false;
196+};
197+
198+interface ForgejoRepoSummary {
199+ description: string;
200+ clone_url: string;
201+ empty: boolean;
202+}
203+
204+interface ForgejoCommit {
205+ sha: string;
206+ commit: { message: string; author: { name: string; date: string } };
207+}
208+
209+const renderRepoView = async (owner: string, repo: string): Promise<Response> => {
210+ const repoApi = `${FORGEJO_INTERNAL}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
211+ const repoRes = await fetch(repoApi);
212+ if (repoRes.status === 404) {
213+ const html = await renderNotFound(`/${owner}/${repo}`);
214+ return htmlResponse(html, 404);
215+ }
216+ if (!repoRes.ok) {
217+ const html = await renderPage({
218+ title: `${owner}/${repo} — tdd.md`,
219+ bodyMarkdown: `# ${owner}/${repo}\n\n> repository unavailable`,
220+ });
221+ return htmlResponse(html, 502);
222+ }
223+ const info = (await repoRes.json()) as ForgejoRepoSummary;
224+
225+ let commitList = "> No commits yet.";
226+ if (!info.empty) {
227+ const commitsRes = await fetch(`${repoApi}/commits?limit=8&stat=false`);
228+ if (commitsRes.ok) {
229+ const commits = (await commitsRes.json()) as ForgejoCommit[];
230+ if (commits.length > 0) {
231+ commitList = commits
232+ .map((c) => {
233+ const sha = c.sha.slice(0, 7);
234+ const subject = c.commit.message.split("\n")[0]!;
235+ const escaped = subject.replace(/\|/g, "\\|");
236+ return `| \`${sha}\` | ${escaped} | ${c.commit.author.name} |`;
237+ })
238+ .join("\n");
239+ commitList = `| sha | message | author |\n|---|---|---|\n${commitList}`;
240+ }
241+ }
242+ }
243+
244+ const cloneUrl = info.clone_url || `https://tdd.md/${owner}/${repo}.git`;
245+
246+ const body = `# ${owner}/${repo}
247+
248+${info.description ? `> ${info.description}\n` : ""}
249+
250+## clone
251+
252+\`\`\`
253+git clone ${cloneUrl}
254+\`\`\`
255+
256+## recent commits
257+
258+${commitList}
259+
260+[← agents/${owner}](/agents/${owner})
261+`;
262+
263+ const html = await renderPage({
264+ title: `${owner}/${repo} — tdd.md`,
265+ bodyMarkdown: body,
266+ ogPath: `https://tdd.md/${owner}/${repo}`,
267+ active: "agents",
268+ });
269+ return htmlResponse(html);
270+};
271+
137272 const port = Number(process.env.PORT ?? 3000);
138273
139274 const server = Bun.serve({
@@ -279,6 +414,20 @@ When you push, the judge replays your commits and posts the verdict at [/agents/
279414
280415 async fetch(req) {
281416 const url = new URL(req.url);
417+
418+ // Git smart-HTTP and dumb-HTTP — proxy raw to Forgejo.
419+ if (isGitProtocol(url.pathname, url.searchParams)) {
420+ return proxyToForgejo(req, url.pathname + url.search);
421+ }
422+
423+ // Bare repo URL: /<owner>/<repo> — render Bun-native view via Forgejo API.
424+ // Two segments only, no trailing path. Reserved top-level paths are
425+ // already matched by explicit routes above, so they never reach here.
426+ const repoMatch = url.pathname.match(/^\/([A-Za-z0-9][A-Za-z0-9-]*)\/([A-Za-z0-9][A-Za-z0-9._-]*)\/?$/);
427+ if (repoMatch) {
428+ return renderRepoView(repoMatch[1]!, repoMatch[2]!);
429+ }
430+
282431 const html = await renderNotFound(url.pathname);
283432 return htmlResponse(html, 404);
284433 },