syntaxai/tdd.md · commit dac87f8

Add GitHub-OAuth agent registration on tdd.md/agents/register

Web-OAuth flow: agent visits /agents/register → /auth/github/start →
GitHub consent → /auth/github/callback. The callback resolves the GitHub
identity, creates (or rotates) a Forgejo account under the same username,
issues a fresh push token (write:repository scope), creates the
string-calc submission repo, and renders a one-time welcome page with
the token + clone URL. State is verified via an HttpOnly Lax cookie.

The Bun container talks to Forgejo over the host network
(host.containers.internal:44400) so registration doesn't loop back through
Cloudflare. Three new podman secrets carry the GitHub client_secret,
the Forgejo admin token (write:admin), and a session secret. The OAuth
client_id is public and lives in the Quadlet env directly.

Forgejo emails use GitHub's noreply format ([email protected])
so registrations never collide with the admin's email. Failures surface
as 4xx so Cloudflare doesn't replace our styled error page with its own.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
author
syntaxai <[email protected]>
date
2026-05-03 16:30:05 +01:00
parent
652b018
commit
dac87f857d30dcfdeecbb5d2764c52b37ecb464f

4 files changed · +465 −5

modified scripts/p620/tdd-md.container +13 −0
@@ -13,6 +13,19 @@ Pod=tdd.pod
1313
1414 Environment=PORT=3000
1515 Environment=NODE_ENV=production
16+Environment=BASE_URL=https://tdd.md
17+
18+# Praat met Forgejo via host-network (Forgejo publisht :44400 op de host).
19+# host.containers.internal is de standaard rootless-podman alias voor de host.
20+Environment=FORGEJO_URL=http://host.containers.internal:44400
21+
22+# GitHub OAuth client_id is publiek (verschijnt sowieso in redirect URLs);
23+# client_secret zit in podman secret.
24+Environment=GITHUB_CLIENT_ID=Ov23li9O1wWWJDjlm6dX
25+
26+Secret=tdd_github_client_secret,type=env,target=GITHUB_CLIENT_SECRET
27+Secret=tdd_forgejo_admin_token,type=env,target=FORGEJO_ADMIN_TOKEN
28+Secret=tdd_session_secret,type=env,target=SESSION_SECRET
1629
1730 # Geen PublishPort — pod publisht al :44390 → :3000.
1831
added src/forgejo.ts +200 −0
@@ -0,0 +1,200 @@
1+// Internal URL — Bun container talks to Forgejo via host.containers.internal
2+// (rootless podman's standard hostname for the host network). Falls back to
3+// the public URL for local dev.
4+const FORGEJO_URL = process.env.FORGEJO_URL ?? "https://git.tdd.md";
5+const ADMIN_TOKEN = process.env.FORGEJO_ADMIN_TOKEN ?? "";
6+
7+const adminAuth = (): HeadersInit => ({
8+ Authorization: `token ${ADMIN_TOKEN}`,
9+});
10+
11+const userAuth = (username: string, password: string): HeadersInit => ({
12+ Authorization: `Basic ${btoa(`${username}:${password}`)}`,
13+});
14+
15+export const isConfigured = (): boolean => ADMIN_TOKEN !== "";
16+
17+export const userExists = async (username: string): Promise<boolean> => {
18+ const res = await fetch(`${FORGEJO_URL}/api/v1/users/${encodeURIComponent(username)}`, {
19+ headers: adminAuth(),
20+ });
21+ return res.status === 200;
22+};
23+
24+export const createUser = async (params: {
25+ username: string;
26+ email: string;
27+ password: string;
28+ fullName?: string;
29+}): Promise<void> => {
30+ const res = await fetch(`${FORGEJO_URL}/api/v1/admin/users`, {
31+ method: "POST",
32+ headers: { ...adminAuth(), "Content-Type": "application/json" },
33+ body: JSON.stringify({
34+ username: params.username,
35+ email: params.email,
36+ password: params.password,
37+ full_name: params.fullName ?? params.username,
38+ must_change_password: false,
39+ send_notify: false,
40+ }),
41+ });
42+ if (!res.ok) {
43+ const text = await res.text();
44+ throw new Error(`forgejo createUser ${res.status}: ${text}`);
45+ }
46+};
47+
48+export const setUserPassword = async (username: string, password: string): Promise<void> => {
49+ const res = await fetch(`${FORGEJO_URL}/api/v1/admin/users/${encodeURIComponent(username)}`, {
50+ method: "PATCH",
51+ headers: { ...adminAuth(), "Content-Type": "application/json" },
52+ body: JSON.stringify({
53+ password,
54+ must_change_password: false,
55+ source_id: 0,
56+ login_name: username,
57+ }),
58+ });
59+ if (!res.ok) {
60+ const text = await res.text();
61+ throw new Error(`forgejo setUserPassword ${res.status}: ${text}`);
62+ }
63+};
64+
65+export const repoExists = async (owner: string, repo: string): Promise<boolean> => {
66+ const res = await fetch(`${FORGEJO_URL}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, {
67+ headers: adminAuth(),
68+ });
69+ return res.status === 200;
70+};
71+
72+export const createRepoForUser = async (params: {
73+ username: string;
74+ name: string;
75+ description?: string;
76+}): Promise<void> => {
77+ const res = await fetch(`${FORGEJO_URL}/api/v1/admin/users/${encodeURIComponent(params.username)}/repos`, {
78+ method: "POST",
79+ headers: { ...adminAuth(), "Content-Type": "application/json" },
80+ body: JSON.stringify({
81+ name: params.name,
82+ description: params.description ?? "",
83+ private: false,
84+ auto_init: true,
85+ default_branch: "main",
86+ readme: "Default",
87+ }),
88+ });
89+ if (!res.ok) {
90+ const text = await res.text();
91+ throw new Error(`forgejo createRepo ${res.status}: ${text}`);
92+ }
93+};
94+
95+interface TokenInfo {
96+ id: number;
97+ name: string;
98+}
99+
100+const listTokens = async (username: string, password: string): Promise<TokenInfo[]> => {
101+ const res = await fetch(`${FORGEJO_URL}/api/v1/users/${encodeURIComponent(username)}/tokens`, {
102+ headers: userAuth(username, password),
103+ });
104+ if (!res.ok) return [];
105+ return (await res.json()) as TokenInfo[];
106+};
107+
108+const deleteToken = async (username: string, password: string, tokenId: number): Promise<void> => {
109+ await fetch(`${FORGEJO_URL}/api/v1/users/${encodeURIComponent(username)}/tokens/${tokenId}`, {
110+ method: "DELETE",
111+ headers: userAuth(username, password),
112+ });
113+};
114+
115+export const createPushToken = async (params: {
116+ username: string;
117+ password: string;
118+ name: string;
119+}): Promise<string> => {
120+ // Revoke any existing tokens with the same name so re-registration always
121+ // returns a fresh one and the previous one is invalidated.
122+ const existing = await listTokens(params.username, params.password);
123+ for (const t of existing) {
124+ if (t.name === params.name) {
125+ await deleteToken(params.username, params.password, t.id);
126+ }
127+ }
128+
129+ const res = await fetch(`${FORGEJO_URL}/api/v1/users/${encodeURIComponent(params.username)}/tokens`, {
130+ method: "POST",
131+ headers: { ...userAuth(params.username, params.password), "Content-Type": "application/json" },
132+ body: JSON.stringify({
133+ name: params.name,
134+ scopes: ["write:repository"],
135+ }),
136+ });
137+ if (!res.ok) {
138+ const text = await res.text();
139+ throw new Error(`forgejo createPushToken ${res.status}: ${text}`);
140+ }
141+ const data = (await res.json()) as { sha1: string };
142+ return data.sha1;
143+};
144+
145+const randomPassword = (): string =>
146+ Array.from(crypto.getRandomValues(new Uint8Array(32)))
147+ .map((b) => b.toString(16).padStart(2, "0"))
148+ .join("");
149+
150+export interface AgentRegistration {
151+ username: string;
152+ pushToken: string;
153+ repoCloneUrl: string;
154+ isNew: boolean;
155+}
156+
157+// Idempotent: if the user exists, reset their password and rotate the push
158+// token. Always also ensures the kata repo exists.
159+export const registerAgent = async (params: {
160+ username: string;
161+ email: string;
162+ fullName?: string;
163+ kata?: string;
164+}): Promise<AgentRegistration> => {
165+ const password = randomPassword();
166+ const isNew = !(await userExists(params.username));
167+
168+ if (isNew) {
169+ await createUser({
170+ username: params.username,
171+ email: params.email,
172+ password,
173+ fullName: params.fullName,
174+ });
175+ } else {
176+ await setUserPassword(params.username, password);
177+ }
178+
179+ const pushToken = await createPushToken({
180+ username: params.username,
181+ password,
182+ name: "tdd-md-push",
183+ });
184+
185+ const kata = params.kata ?? "string-calc";
186+ if (!(await repoExists(params.username, kata))) {
187+ await createRepoForUser({
188+ username: params.username,
189+ name: kata,
190+ description: `${params.username}'s submission for the ${kata} kata`,
191+ });
192+ }
193+
194+ return {
195+ username: params.username,
196+ pushToken,
197+ repoCloneUrl: `https://git.tdd.md/${params.username}/${kata}.git`,
198+ isNew,
199+ };
200+};
added src/github_oauth.ts +80 −0
@@ -0,0 +1,80 @@
1+const CLIENT_ID = process.env.GITHUB_CLIENT_ID ?? "";
2+const CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET ?? "";
3+
4+export interface GithubUser {
5+ login: string;
6+ id: number;
7+ email: string | null;
8+ avatar_url: string;
9+ name: string | null;
10+}
11+
12+export interface GithubEmail {
13+ email: string;
14+ primary: boolean;
15+ verified: boolean;
16+ visibility: string | null;
17+}
18+
19+export const isConfigured = (): boolean => CLIENT_ID !== "" && CLIENT_SECRET !== "";
20+
21+export const authorizeUrl = (state: string, redirectUri: string): string => {
22+ const params = new URLSearchParams({
23+ client_id: CLIENT_ID,
24+ redirect_uri: redirectUri,
25+ scope: "read:user user:email",
26+ state,
27+ allow_signup: "true",
28+ });
29+ return `https://github.com/login/oauth/authorize?${params}`;
30+};
31+
32+export const exchangeCode = async (code: string, redirectUri: string): Promise<string> => {
33+ const res = await fetch("https://github.com/login/oauth/access_token", {
34+ method: "POST",
35+ headers: {
36+ Accept: "application/json",
37+ "Content-Type": "application/json",
38+ },
39+ body: JSON.stringify({
40+ client_id: CLIENT_ID,
41+ client_secret: CLIENT_SECRET,
42+ code,
43+ redirect_uri: redirectUri,
44+ }),
45+ });
46+ if (!res.ok) {
47+ throw new Error(`github token exchange failed: ${res.status}`);
48+ }
49+ const data = (await res.json()) as { access_token?: string; error?: string; error_description?: string };
50+ if (!data.access_token) {
51+ throw new Error(`github token exchange returned no token: ${data.error_description ?? data.error ?? "unknown"}`);
52+ }
53+ return data.access_token;
54+};
55+
56+export const fetchUser = async (accessToken: string): Promise<GithubUser> => {
57+ const res = await fetch("https://api.github.com/user", {
58+ headers: {
59+ Authorization: `token ${accessToken}`,
60+ Accept: "application/vnd.github+json",
61+ "User-Agent": "tdd.md",
62+ },
63+ });
64+ if (!res.ok) throw new Error(`github user fetch failed: ${res.status}`);
65+ return (await res.json()) as GithubUser;
66+};
67+
68+export const fetchPrimaryEmail = async (accessToken: string): Promise<string | null> => {
69+ const res = await fetch("https://api.github.com/user/emails", {
70+ headers: {
71+ Authorization: `token ${accessToken}`,
72+ Accept: "application/vnd.github+json",
73+ "User-Agent": "tdd.md",
74+ },
75+ });
76+ if (!res.ok) return null;
77+ const emails = (await res.json()) as GithubEmail[];
78+ const verified = emails.filter((e) => e.verified);
79+ return verified.find((e) => e.primary)?.email ?? verified[0]?.email ?? null;
80+};
modified src/server.ts +172 −5
@@ -1,8 +1,13 @@
11 import { renderPage, renderNotFound } from "./render";
2+import * as github from "./github_oauth";
3+import * as forgejo from "./forgejo";
24
35 const HOME_MD = "./content/home.md";
46 const GAME_DIR = "./content/games";
57
8+const BASE_URL = process.env.BASE_URL ?? "https://tdd.md";
9+const CALLBACK_URL = `${BASE_URL}/auth/github/callback`;
10+
611 const homeBody = await Bun.file(HOME_MD).text();
712 const HOME_HTML = await renderPage({
813 title: "tdd.md — a TDD game for AI agents",
@@ -41,7 +46,9 @@ const agentsIndexBody = `# agents
4146
4247 > No agents have played yet. Be the first.
4348
44-To register an agent, contact the admin. Public sign-up will open with the first scored run.
49+[ Register your agent → ](/agents/register)
50+
51+Public registration is open. We use GitHub to verify identity and pick your username.
4552 `;
4653
4754 const AGENTS_INDEX_HTML = await renderPage({
@@ -51,6 +58,31 @@ const AGENTS_INDEX_HTML = await renderPage({
5158 active: "agents",
5259 });
5360
61+const REGISTER_BODY = `# register
62+
63+> Sign in with GitHub to create your tdd.md agent.
64+
65+## what we ask GitHub for
66+- your username
67+- your primary verified email
68+
69+That's it — no repo access, no anything else.
70+
71+## what you get
72+- a public agent account at \`git.tdd.md/<your-github-name>\`
73+- a push token (shown once)
74+- an empty repo for the first kata, ready to push to
75+
76+[ sign in with github → ](/auth/github/start)
77+`;
78+
79+const REGISTER_HTML = await renderPage({
80+ title: "register — tdd.md",
81+ bodyMarkdown: REGISTER_BODY,
82+ ogPath: "https://tdd.md/agents/register",
83+ active: "agents",
84+});
85+
5486 const leaderboardBody = `# leaderboard
5587
5688 > Empty.
@@ -65,8 +97,42 @@ const LEADERBOARD_HTML = await renderPage({
6597 active: "leaderboard",
6698 });
6799
68-const htmlResponse = (html: string) =>
69- new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } });
100+const htmlResponse = (html: string, status = 200) =>
101+ new Response(html, { status, headers: { "Content-Type": "text/html; charset=utf-8" } });
102+
103+const errorPage = async (message: string, status = 400): Promise<Response> => {
104+ const html = await renderPage({
105+ title: "error — tdd.md",
106+ bodyMarkdown: `# error\n\n> ${message}\n\n[← back](/agents/register)`,
107+ active: "agents",
108+ });
109+ return htmlResponse(html, status);
110+};
111+
112+const randomHex = (bytes: number): string =>
113+ Array.from(crypto.getRandomValues(new Uint8Array(bytes)))
114+ .map((b) => b.toString(16).padStart(2, "0"))
115+ .join("");
116+
117+const parseCookies = (header: string | null): Record<string, string> => {
118+ const out: Record<string, string> = {};
119+ if (!header) return out;
120+ for (const part of header.split(";")) {
121+ const idx = part.indexOf("=");
122+ if (idx === -1) continue;
123+ const name = part.slice(0, idx).trim();
124+ const value = part.slice(idx + 1).trim();
125+ if (name) out[name] = decodeURIComponent(value);
126+ }
127+ return out;
128+};
129+
130+const timingSafeEqual = (a: string, b: string): boolean => {
131+ if (a.length !== b.length) return false;
132+ let r = 0;
133+ for (let i = 0; i < a.length; i++) r |= a.charCodeAt(i) ^ b.charCodeAt(i);
134+ return r === 0;
135+};
70136
71137 const port = Number(process.env.PORT ?? 3000);
72138
@@ -84,10 +150,11 @@ const server = Bun.serve({
84150 const res = await renderKata(req.params.kata);
85151 if (res) return res;
86152 const html = await renderNotFound(`/games/${req.params.kata}`);
87- return new Response(html, { status: 404, headers: { "Content-Type": "text/html; charset=utf-8" } });
153+ return htmlResponse(html, 404);
88154 },
89155
90156 "/agents": htmlResponse(AGENTS_INDEX_HTML),
157+ "/agents/register": htmlResponse(REGISTER_HTML),
91158 "/agents/:name": async (req) => {
92159 const html = await renderPage({
93160 title: `${req.params.name} — agents — tdd.md`,
@@ -108,12 +175,112 @@ const server = Bun.serve({
108175 },
109176
110177 "/leaderboard": htmlResponse(LEADERBOARD_HTML),
178+
179+ "/auth/github/start": (_req) => {
180+ if (!github.isConfigured() || !forgejo.isConfigured()) {
181+ return errorPage("registration is not configured on this server", 503);
182+ }
183+ const nonce = randomHex(16);
184+ return new Response(null, {
185+ status: 302,
186+ headers: {
187+ Location: github.authorizeUrl(nonce, CALLBACK_URL),
188+ "Set-Cookie": `tdd_oauth_state=${nonce}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=600`,
189+ },
190+ });
191+ },
192+
193+ "/auth/github/callback": async (req) => {
194+ const url = new URL(req.url);
195+ const code = url.searchParams.get("code");
196+ const state = url.searchParams.get("state");
197+ if (!code || !state) return errorPage("missing code or state");
198+
199+ const cookies = parseCookies(req.headers.get("cookie"));
200+ const cookieState = cookies.tdd_oauth_state;
201+ if (!cookieState || !timingSafeEqual(cookieState, state)) {
202+ return errorPage("state mismatch — open the registration page again and retry");
203+ }
204+
205+ let username: string;
206+ let email: string;
207+ let fullName: string | null;
208+ try {
209+ const accessToken = await github.exchangeCode(code, CALLBACK_URL);
210+ const user = await github.fetchUser(accessToken);
211+ username = user.login;
212+ fullName = user.name;
213+ // GitHub's noreply email format: unique per account, never collides
214+ // with another Forgejo user. We don't need a deliverable address —
215+ // agents authenticate by token, not by email reset flow.
216+ email = `${user.id}+${user.login}@users.noreply.github.com`;
217+ } catch (err) {
218+ return errorPage(`github oauth failed: ${(err as Error).message}`, 400);
219+ }
220+
221+ let reg: forgejo.AgentRegistration;
222+ try {
223+ reg = await forgejo.registerAgent({
224+ username,
225+ email,
226+ fullName: fullName ?? undefined,
227+ });
228+ } catch (err) {
229+ return errorPage(`failed to create your agent: ${(err as Error).message}`, 422);
230+ }
231+
232+ const verb = reg.isNew ? "created" : "rotated";
233+ const body = `# welcome, ${reg.username}
234+
235+> Your tdd.md agent has been ${verb}. **Save the token below — this page is the only time you'll see it.** If you lose it, [register again](/agents/register) to issue a fresh one (the old one will stop working).
236+
237+## push token
238+
239+\`\`\`
240+${reg.pushToken}
241+\`\`\`
242+
243+## kata: string-calc
244+
245+Your repo is at [\`git.tdd.md/${reg.username}/string-calc\`](https://git.tdd.md/${reg.username}/string-calc), already initialized with a default branch \`main\`.
246+
247+\`\`\`
248+git clone ${reg.repoCloneUrl}
249+cd string-calc
250+
251+# play the kata, commit per phase
252+# red: commit a failing test
253+# green: commit the impl that makes it pass
254+# refactor: commit a structural change with tests staying green
255+
256+git push
257+# username: ${reg.username}
258+# password: <paste the token above>
259+\`\`\`
260+
261+When you push, the judge replays your commits and posts the verdict at [/agents/${reg.username}/string-calc](/agents/${reg.username}/string-calc).
262+
263+[← spec](/games/string-calc) · [all agents](/agents)
264+`;
265+
266+ const html = await renderPage({
267+ title: `welcome ${reg.username} — tdd.md`,
268+ bodyMarkdown: body,
269+ active: "agents",
270+ });
271+ return new Response(html, {
272+ headers: {
273+ "Content-Type": "text/html; charset=utf-8",
274+ "Set-Cookie": "tdd_oauth_state=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0",
275+ },
276+ });
277+ },
111278 },
112279
113280 async fetch(req) {
114281 const url = new URL(req.url);
115282 const html = await renderNotFound(url.pathname);
116- return new Response(html, { status: 404, headers: { "Content-Type": "text/html; charset=utf-8" } });
283+ return htmlResponse(html, 404);
117284 },
118285
119286 error(err) {