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]>
4 files changed · +465 −5
scripts/p620/tdd-md.container
+13
−0
| @@ -13,6 +13,19 @@ Pod=tdd.pod | ||
| 13 | 13 | |
| 14 | 14 | Environment=PORT=3000 |
| 15 | 15 | 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 | |
| 16 | 29 | |
| 17 | 30 | # Geen PublishPort — pod publisht al :44390 → :3000. |
| 18 | 31 | |
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 | +}; | |
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 | +}; | |
src/server.ts
+172
−5
| @@ -1,8 +1,13 @@ | ||
| 1 | 1 | import { renderPage, renderNotFound } from "./render"; |
| 2 | +import * as github from "./github_oauth"; | |
| 3 | +import * as forgejo from "./forgejo"; | |
| 2 | 4 | |
| 3 | 5 | const HOME_MD = "./content/home.md"; |
| 4 | 6 | const GAME_DIR = "./content/games"; |
| 5 | 7 | |
| 8 | +const BASE_URL = process.env.BASE_URL ?? "https://tdd.md"; | |
| 9 | +const CALLBACK_URL = `${BASE_URL}/auth/github/callback`; | |
| 10 | + | |
| 6 | 11 | const homeBody = await Bun.file(HOME_MD).text(); |
| 7 | 12 | const HOME_HTML = await renderPage({ |
| 8 | 13 | title: "tdd.md — a TDD game for AI agents", |
| @@ -41,7 +46,9 @@ const agentsIndexBody = `# agents | ||
| 41 | 46 | |
| 42 | 47 | > No agents have played yet. Be the first. |
| 43 | 48 | |
| 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. | |
| 45 | 52 | `; |
| 46 | 53 | |
| 47 | 54 | const AGENTS_INDEX_HTML = await renderPage({ |
| @@ -51,6 +58,31 @@ const AGENTS_INDEX_HTML = await renderPage({ | ||
| 51 | 58 | active: "agents", |
| 52 | 59 | }); |
| 53 | 60 | |
| 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 | + | |
| 54 | 86 | const leaderboardBody = `# leaderboard |
| 55 | 87 | |
| 56 | 88 | > Empty. |
| @@ -65,8 +97,42 @@ const LEADERBOARD_HTML = await renderPage({ | ||
| 65 | 97 | active: "leaderboard", |
| 66 | 98 | }); |
| 67 | 99 | |
| 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 | +}; | |
| 70 | 136 | |
| 71 | 137 | const port = Number(process.env.PORT ?? 3000); |
| 72 | 138 | |
| @@ -84,10 +150,11 @@ const server = Bun.serve({ | ||
| 84 | 150 | const res = await renderKata(req.params.kata); |
| 85 | 151 | if (res) return res; |
| 86 | 152 | 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); | |
| 88 | 154 | }, |
| 89 | 155 | |
| 90 | 156 | "/agents": htmlResponse(AGENTS_INDEX_HTML), |
| 157 | + "/agents/register": htmlResponse(REGISTER_HTML), | |
| 91 | 158 | "/agents/:name": async (req) => { |
| 92 | 159 | const html = await renderPage({ |
| 93 | 160 | title: `${req.params.name} — agents — tdd.md`, |
| @@ -108,12 +175,112 @@ const server = Bun.serve({ | ||
| 108 | 175 | }, |
| 109 | 176 | |
| 110 | 177 | "/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 | + }, | |
| 111 | 278 | }, |
| 112 | 279 | |
| 113 | 280 | async fetch(req) { |
| 114 | 281 | const url = new URL(req.url); |
| 115 | 282 | 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); | |
| 117 | 284 | }, |
| 118 | 285 | |
| 119 | 286 | error(err) { |