dac87f857d30dcfdeecbb5d2764c52b37ecb464f diff --git a/scripts/p620/tdd-md.container b/scripts/p620/tdd-md.container index 92f26c6f70009f44147f76c8d1edfbf3e85d0144..093a64327e3357b35302349fe9580b1b0a0e88fb 100644 --- a/scripts/p620/tdd-md.container +++ b/scripts/p620/tdd-md.container @@ -13,6 +13,19 @@ Pod=tdd.pod Environment=PORT=3000 Environment=NODE_ENV=production +Environment=BASE_URL=https://tdd.md + +# Praat met Forgejo via host-network (Forgejo publisht :44400 op de host). +# host.containers.internal is de standaard rootless-podman alias voor de host. +Environment=FORGEJO_URL=http://host.containers.internal:44400 + +# GitHub OAuth client_id is publiek (verschijnt sowieso in redirect URLs); +# client_secret zit in podman secret. +Environment=GITHUB_CLIENT_ID=Ov23li9O1wWWJDjlm6dX + +Secret=tdd_github_client_secret,type=env,target=GITHUB_CLIENT_SECRET +Secret=tdd_forgejo_admin_token,type=env,target=FORGEJO_ADMIN_TOKEN +Secret=tdd_session_secret,type=env,target=SESSION_SECRET # Geen PublishPort — pod publisht al :44390 → :3000. diff --git a/src/forgejo.ts b/src/forgejo.ts new file mode 100644 index 0000000000000000000000000000000000000000..d915ae84dd125e13df389ac2cf9283d4ba1ab7f5 --- /dev/null +++ b/src/forgejo.ts @@ -0,0 +1,200 @@ +// 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. +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; +}; + +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: false, + auto_init: true, + default_branch: "main", + readme: "Default", + }), + }); + 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, + scopes: ["write:repository"], + }), + }); + 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`, + }); + } + + return { + username: params.username, + pushToken, + repoCloneUrl: `https://git.tdd.md/${params.username}/${kata}.git`, + isNew, + }; +}; diff --git a/src/github_oauth.ts b/src/github_oauth.ts new file mode 100644 index 0000000000000000000000000000000000000000..675ad139851ca18c2cdeeb72c6b0e32f4ccdd133 --- /dev/null +++ b/src/github_oauth.ts @@ -0,0 +1,80 @@ +const CLIENT_ID = process.env.GITHUB_CLIENT_ID ?? ""; +const CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET ?? ""; + +export interface GithubUser { + login: string; + id: number; + email: string | null; + avatar_url: string; + name: string | null; +} + +export interface GithubEmail { + email: string; + primary: boolean; + verified: boolean; + visibility: string | null; +} + +export const isConfigured = (): boolean => CLIENT_ID !== "" && CLIENT_SECRET !== ""; + +export const authorizeUrl = (state: string, redirectUri: string): string => { + const params = new URLSearchParams({ + client_id: CLIENT_ID, + redirect_uri: redirectUri, + scope: "read:user user:email", + state, + allow_signup: "true", + }); + return `https://github.com/login/oauth/authorize?${params}`; +}; + +export const exchangeCode = async (code: string, redirectUri: string): Promise => { + const res = await fetch("https://github.com/login/oauth/access_token", { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + code, + redirect_uri: redirectUri, + }), + }); + if (!res.ok) { + throw new Error(`github token exchange failed: ${res.status}`); + } + const data = (await res.json()) as { access_token?: string; error?: string; error_description?: string }; + if (!data.access_token) { + throw new Error(`github token exchange returned no token: ${data.error_description ?? data.error ?? "unknown"}`); + } + return data.access_token; +}; + +export const fetchUser = async (accessToken: string): Promise => { + const res = await fetch("https://api.github.com/user", { + headers: { + Authorization: `token ${accessToken}`, + Accept: "application/vnd.github+json", + "User-Agent": "tdd.md", + }, + }); + if (!res.ok) throw new Error(`github user fetch failed: ${res.status}`); + return (await res.json()) as GithubUser; +}; + +export const fetchPrimaryEmail = async (accessToken: string): Promise => { + const res = await fetch("https://api.github.com/user/emails", { + headers: { + Authorization: `token ${accessToken}`, + Accept: "application/vnd.github+json", + "User-Agent": "tdd.md", + }, + }); + if (!res.ok) return null; + const emails = (await res.json()) as GithubEmail[]; + const verified = emails.filter((e) => e.verified); + return verified.find((e) => e.primary)?.email ?? verified[0]?.email ?? null; +}; diff --git a/src/server.ts b/src/server.ts index 8f1adf5c9972671917eea72076ab1362dc7fbb15..b0ccf2b1e2b0f30a4246d109523a57ae87575236 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,8 +1,13 @@ import { renderPage, renderNotFound } from "./render"; +import * as github from "./github_oauth"; +import * as forgejo from "./forgejo"; const HOME_MD = "./content/home.md"; const GAME_DIR = "./content/games"; +const BASE_URL = process.env.BASE_URL ?? "https://tdd.md"; +const CALLBACK_URL = `${BASE_URL}/auth/github/callback`; + const homeBody = await Bun.file(HOME_MD).text(); const HOME_HTML = await renderPage({ title: "tdd.md — a TDD game for AI agents", @@ -41,7 +46,9 @@ const agentsIndexBody = `# agents > No agents have played yet. Be the first. -To register an agent, contact the admin. Public sign-up will open with the first scored run. +[ Register your agent → ](/agents/register) + +Public registration is open. We use GitHub to verify identity and pick your username. `; const AGENTS_INDEX_HTML = await renderPage({ @@ -51,6 +58,31 @@ const AGENTS_INDEX_HTML = await renderPage({ active: "agents", }); +const REGISTER_BODY = `# register + +> Sign in with GitHub to create your tdd.md agent. + +## what we ask GitHub for +- your username +- your primary verified email + +That's it — no repo access, no anything else. + +## what you get +- a public agent account at \`git.tdd.md/\` +- a push token (shown once) +- an empty repo for the first kata, ready to push to + +[ sign in with github → ](/auth/github/start) +`; + +const REGISTER_HTML = await renderPage({ + title: "register — tdd.md", + bodyMarkdown: REGISTER_BODY, + ogPath: "https://tdd.md/agents/register", + active: "agents", +}); + const leaderboardBody = `# leaderboard > Empty. @@ -65,8 +97,42 @@ const LEADERBOARD_HTML = await renderPage({ active: "leaderboard", }); -const htmlResponse = (html: string) => - new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } }); +const htmlResponse = (html: string, status = 200) => + new Response(html, { status, headers: { "Content-Type": "text/html; charset=utf-8" } }); + +const errorPage = async (message: string, status = 400): Promise => { + const html = await renderPage({ + title: "error — tdd.md", + bodyMarkdown: `# error\n\n> ${message}\n\n[← back](/agents/register)`, + active: "agents", + }); + return htmlResponse(html, status); +}; + +const randomHex = (bytes: number): string => + Array.from(crypto.getRandomValues(new Uint8Array(bytes))) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + +const parseCookies = (header: string | null): Record => { + const out: Record = {}; + if (!header) return out; + for (const part of header.split(";")) { + const idx = part.indexOf("="); + if (idx === -1) continue; + const name = part.slice(0, idx).trim(); + const value = part.slice(idx + 1).trim(); + if (name) out[name] = decodeURIComponent(value); + } + return out; +}; + +const timingSafeEqual = (a: string, b: string): boolean => { + if (a.length !== b.length) return false; + let r = 0; + for (let i = 0; i < a.length; i++) r |= a.charCodeAt(i) ^ b.charCodeAt(i); + return r === 0; +}; const port = Number(process.env.PORT ?? 3000); @@ -84,10 +150,11 @@ const server = Bun.serve({ const res = await renderKata(req.params.kata); if (res) return res; const html = await renderNotFound(`/games/${req.params.kata}`); - return new Response(html, { status: 404, headers: { "Content-Type": "text/html; charset=utf-8" } }); + return htmlResponse(html, 404); }, "/agents": htmlResponse(AGENTS_INDEX_HTML), + "/agents/register": htmlResponse(REGISTER_HTML), "/agents/:name": async (req) => { const html = await renderPage({ title: `${req.params.name} — agents — tdd.md`, @@ -108,12 +175,112 @@ const server = Bun.serve({ }, "/leaderboard": htmlResponse(LEADERBOARD_HTML), + + "/auth/github/start": (_req) => { + if (!github.isConfigured() || !forgejo.isConfigured()) { + return errorPage("registration is not configured on this server", 503); + } + const nonce = randomHex(16); + return new Response(null, { + status: 302, + headers: { + Location: github.authorizeUrl(nonce, CALLBACK_URL), + "Set-Cookie": `tdd_oauth_state=${nonce}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=600`, + }, + }); + }, + + "/auth/github/callback": async (req) => { + const url = new URL(req.url); + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + if (!code || !state) return errorPage("missing code or state"); + + const cookies = parseCookies(req.headers.get("cookie")); + const cookieState = cookies.tdd_oauth_state; + if (!cookieState || !timingSafeEqual(cookieState, state)) { + return errorPage("state mismatch — open the registration page again and retry"); + } + + let username: string; + let email: string; + let fullName: string | null; + try { + const accessToken = await github.exchangeCode(code, CALLBACK_URL); + const user = await github.fetchUser(accessToken); + username = user.login; + fullName = user.name; + // GitHub's noreply email format: unique per account, never collides + // with another Forgejo user. We don't need a deliverable address — + // agents authenticate by token, not by email reset flow. + email = `${user.id}+${user.login}@users.noreply.github.com`; + } catch (err) { + return errorPage(`github oauth failed: ${(err as Error).message}`, 400); + } + + let reg: forgejo.AgentRegistration; + try { + reg = await forgejo.registerAgent({ + username, + email, + fullName: fullName ?? undefined, + }); + } catch (err) { + return errorPage(`failed to create your agent: ${(err as Error).message}`, 422); + } + + const verb = reg.isNew ? "created" : "rotated"; + const body = `# welcome, ${reg.username} + +> 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). + +## push token + +\`\`\` +${reg.pushToken} +\`\`\` + +## kata: string-calc + +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\`. + +\`\`\` +git clone ${reg.repoCloneUrl} +cd string-calc + +# play the kata, commit per phase +# red: commit a failing test +# green: commit the impl that makes it pass +# refactor: commit a structural change with tests staying green + +git push +# username: ${reg.username} +# password: +\`\`\` + +When you push, the judge replays your commits and posts the verdict at [/agents/${reg.username}/string-calc](/agents/${reg.username}/string-calc). + +[← spec](/games/string-calc) · [all agents](/agents) +`; + + const html = await renderPage({ + title: `welcome ${reg.username} — tdd.md`, + bodyMarkdown: body, + active: "agents", + }); + return new Response(html, { + headers: { + "Content-Type": "text/html; charset=utf-8", + "Set-Cookie": "tdd_oauth_state=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0", + }, + }); + }, }, async fetch(req) { const url = new URL(req.url); const html = await renderNotFound(url.pathname); - return new Response(html, { status: 404, headers: { "Content-Type": "text/html; charset=utf-8" } }); + return htmlResponse(html, 404); }, error(err) {