c9e085af6f774c2a3239b94b4eaf4145bb722c48 diff --git a/src/c21_app.ts b/src/c21_app.ts index 09f8016c77a04ce7913fa892f61961a106e090b0..9b4185bb63af9ebbc3ede62e18a66da41e29b40a 100644 --- a/src/c21_app.ts +++ b/src/c21_app.ts @@ -8,35 +8,14 @@ import { htmlResponse, } from "./c51_render_layout.ts"; import { renderDocsPage } from "./c51_render_docs_layout.ts"; -import { - projectsLandingMd, - projectRegisterMd, - projectDetailMd, -} from "./c51_render_projects.ts"; -import { - FORGEJO_URL, - adminApiHeaders, - proxyToForgejo, -} from "./c14_forgejo.ts"; -import { fetchProjectConfig } from "./c14_github.ts"; import { listGames, loadGame } from "./c31_games.ts"; import { ALL_POSTS } from "./c31_blog.ts"; import { ALL_GUIDES } from "./c31_guides.ts"; import { ALL_SAMA } from "./c31_sama.ts"; -import { parseRepoIdentifier } from "./c31_project_config.ts"; -import { judge } from "./c32_judge.ts"; import { getViewer, sessionCookieHeader, - timingSafeEqual, - hmacSha256Hex, } from "./c32_session.ts"; -import { - listActiveProjects, - getProject, - upsertProject, -} from "./c13_database.ts"; -import { renderRepoView } from "./c21_handlers_repo_view.ts"; import { renderAgentsIndex, renderAgentDetail } from "./c21_handlers_agents.ts"; import { renderLeaderboard } from "./c21_handlers_leaderboard.ts"; import { startGithubOauth, handleGithubCallback } from "./c21_handlers_auth.ts"; @@ -65,13 +44,20 @@ import { adminDeleteHandler, } from "./c21_handlers_admin.ts"; import { bundleAdminClient } from "./c14_client_bundle.ts"; -import { publicPageHandler, renderPublicPage } from "./c21_handlers_content.ts"; +import { publicPageHandler } from "./c21_handlers_content.ts"; import { rawSourceHandler } from "./c21_handlers_source.ts"; import { commitViewHandler } from "./c21_handlers_commit_view.ts"; +import { appFetch, appError } from "./c21_handlers_fallback.ts"; import { - parseRepoBrowsePath, - repoBrowseHandler, -} from "./c21_handlers_repo_browse.ts"; + projectsLandingHandler, + projectsNewHandler, + projectDetailHandler, +} from "./c21_handlers_projects.ts"; +import { + judgeApiHandler, + agentVisibilityHandler, +} from "./c21_handlers_api_agents.ts"; +import { forgejoWebhookHandler } from "./c21_handlers_webhook.ts"; const HOME_MD = "./content/home.md"; const GAME_DIR = "./content/games"; @@ -169,118 +155,6 @@ const REGISTER_HTML = await renderPage({ noindex: true, }); - -const isGitProtocol = (pathname: string, search: URLSearchParams): boolean => { - if (pathname.includes(".git/") || pathname.endsWith(".git")) return true; - if ( - pathname.endsWith("/info/refs") && - (search.get("service") === "git-upload-pack" || search.get("service") === "git-receive-pack") - ) { - return true; - } - if (pathname.endsWith("/git-upload-pack") || pathname.endsWith("/git-receive-pack")) { - return true; - } - return false; -}; - -// Fallback handler — git-protocol proxy, bare-repo /:owner/:repo view, -// admin multi-segment slugs, and /:owner/:repo.git redirects. Mounted as -// `fetch` on Bun.serve. -const appFetch = async (req: Request): Promise => { - const url = new URL(req.url); - - // Admin edit/delete on multi-segment slugs (company/about, docs/spec/grammar - // etc.). Bun's `:slug` param can't span "/" so anything with two-or-more - // segments after the type slot ends up here. Single-segment is handled - // by the routes table above and never reaches this branch. - const adminEditMulti = url.pathname.match( - /^\/admin\/edit\/(page|post)\/([a-z0-9_\-/]+?)\/?$/, - ); - if (adminEditMulti) { - const reqP = Object.assign(req, { - params: { type: adminEditMulti[1]!, slug: adminEditMulti[2]! }, - }); - return adminEditHandler(reqP); - } - const adminDeleteMulti = url.pathname.match( - /^\/admin\/delete\/(page|post)\/([a-z0-9_\-/]+?)\/?$/, - ); - if (adminDeleteMulti) { - const reqP = Object.assign(req, { - params: { type: adminDeleteMulti[1]!, slug: adminDeleteMulti[2]! }, - }); - return adminDeleteHandler(reqP); - } - - // Public sxdoc-backed pages on multi-segment slugs (e.g. - // /p/company/about, /p/docs/spec/grammar). Single-segment is handled - // by the routes table above. - const publicPageMulti = url.pathname.match(/^\/p\/([a-z0-9_\-/]+?)\/?$/); - if (publicPageMulti) { - return renderPublicPage(publicPageMulti[1]!); - } - - // Bare //.git (no sub-path) is what someone gets when - // they paste the clone URL into a browser. Without intervention our - // proxy hands it to Forgejo, which renders its own repo page — - // Forgejo's chrome leaks onto tdd.md. Redirect to the clean URL - // so the visitor lands on our Bun-native scoreboard instead. Real - // git operations always have sub-paths (/info/refs, /git-upload-pack, - // /objects/...) and continue to be proxied below. - const bareGitUrl = url.pathname.match( - /^\/([A-Za-z0-9][A-Za-z0-9-]*)\/([A-Za-z0-9][A-Za-z0-9._-]*)\.git\/?$/, - ); - if (bareGitUrl) { - return new Response(null, { - status: 302, - headers: { Location: `/${bareGitUrl[1]}/${bareGitUrl[2]}` }, - }); - } - - // SAMA-native repo browse at /GIT/:owner/:repo/{tree,blob,raw}/:ref/. - // The wildcard path needs more flexibility than Bun's :param routes - // give us (no slashes), so we match in the fallback fetch instead. - const gitBrowseMatch = url.pathname.match( - /^\/GIT\/([A-Za-z0-9][A-Za-z0-9._-]+)\/([A-Za-z0-9][A-Za-z0-9._-]+)\/(.+)$/, - ); - if (gitBrowseMatch) { - const owner = gitBrowseMatch[1]!; - const repo = gitBrowseMatch[2]!; - const suffix = gitBrowseMatch[3]!; - // Skip the commit/ shape — that's c21_handlers_commit_view's - // turf and lives as an explicit Bun.serve route above. - if (!suffix.startsWith("commit/")) { - const target = parseRepoBrowsePath(suffix); - if (target !== null) { - return repoBrowseHandler(req, owner, repo, target); - } - } - } - - // Git smart-HTTP and dumb-HTTP — proxy raw to Forgejo. - if (isGitProtocol(url.pathname, url.searchParams)) { - return proxyToForgejo(req, url.pathname + url.search); - } - - // Bare repo URL: // — render Bun-native view via Forgejo API. - // Two segments only, no trailing path. Reserved top-level paths are - // already matched by explicit routes above, so they never reach here. - const repoMatch = url.pathname.match(/^\/([A-Za-z0-9][A-Za-z0-9-]*)\/([A-Za-z0-9][A-Za-z0-9._-]*)\/?$/); - if (repoMatch) { - const viewer = await getViewer(req); - return renderRepoView(repoMatch[1]!, repoMatch[2]!, viewer); - } - - const html = await renderNotFound(url.pathname); - return htmlResponse(html, 404); -}; - -const appError = (err: Error): Response => { - console.error(err); - return new Response("internal error", { status: 500 }); -}; - // --------------------------------------------------------------------- // App factory — c11 calls createApp(port) to start the server. The // routes literal stays inline here so Bun's path-parameter inference @@ -416,89 +290,9 @@ ${rows} return htmlResponse(html); }, - "/projects": async () => { - const projects = listActiveProjects(); - const html = await renderPage({ - title: "Projects — tdd.md", - description: "Real repos opted in to tdd.md scoring. Each project drops .tdd-md.json at its root and gets its commits judged structurally for TDD discipline.", - bodyMarkdown: projectsLandingMd(projects), - ogPath: "https://tdd.md/projects", - }); - return htmlResponse(html); - }, - - "/projects/new": async (req) => { - const viewer = await getViewer(req); - if (req.method === "GET") { - const url = new URL(req.url); - const prefilled = url.searchParams.get("repo") ?? undefined; - const html = await renderPage({ - title: "Register a project — tdd.md", - description: "Onboard a real repo for TDD-discipline scoring. Drops .tdd-md.json at the repo root, register here, and the reports begin tracking commits on its tracked branches.", - bodyMarkdown: projectRegisterMd(viewer, prefilled), - ogPath: "https://tdd.md/projects/new", - noindex: true, - }); - return htmlResponse(html); - } - if (req.method !== "POST") return new Response("method not allowed", { status: 405 }); - if (!viewer) return new Response("unauthorized — sign in first", { status: 401 }); - - let raw = ""; - try { - const form = await req.formData(); - raw = String(form.get("repo") ?? "").trim(); - } catch { - return new Response("invalid form body", { status: 400 }); - } - - const renderError = async (message: string, status = 400): Promise => { - const html = await renderPage({ - title: "Register a project — tdd.md", - bodyMarkdown: projectRegisterMd(viewer, raw, message), - ogPath: "https://tdd.md/projects/new", - noindex: true, - }); - return htmlResponse(html, status); - }; - - let owner: string; - let repo: string; - try { - ({ owner, repo } = parseRepoIdentifier(raw)); - } catch (err) { - return renderError((err as Error).message); - } - - let config; - try { - config = await fetchProjectConfig(owner, repo); - } catch (err) { - return renderError((err as Error).message); - } - - upsertProject(viewer, owner, repo, config); - return new Response(null, { - status: 303, - headers: { Location: `/projects/${owner}/${repo}` }, - }); - }, - - "/projects/:repoOwner/:repoName": async (req) => { - const { repoOwner, repoName } = req.params; - const project = getProject(repoOwner, repoName); - if (!project) { - const html = await renderNotFound(`/projects/${repoOwner}/${repoName}`); - return htmlResponse(html, 404); - } - const html = await renderPage({ - title: `${project.displayName ?? `${project.repoOwner}/${project.repoName}`} — tdd.md`, - description: `${project.repoOwner}/${project.repoName} on tdd.md — ${project.testRunner === "none" ? "trace-mode" : project.testRunner} judging across ${project.trackedBranches.join(", ")}.`, - bodyMarkdown: projectDetailMd(project), - ogPath: `https://tdd.md/projects/${project.repoOwner}/${project.repoName}`, - }); - return htmlResponse(html); - }, + "/projects": projectsLandingHandler, + "/projects/new": projectsNewHandler, + "/projects/:repoOwner/:repoName": projectDetailHandler, "/reports": reportsLandingHandler, "/reports/demo": reportsDemoHandler, @@ -592,111 +386,9 @@ ${rows} "/leaderboard": () => renderLeaderboard(), - "/api/judge/:owner/:repo": async (req) => { - if (req.method !== "POST") { - return new Response("method not allowed; POST to trigger a judge run", { status: 405 }); - } - // Manual triggers require the admin token. Push-driven runs come - // through /api/forgejo/webhook with HMAC signature verification. - const adminToken = process.env.FORGEJO_ADMIN_TOKEN; - const provided = req.headers.get("authorization")?.replace(/^[Bb]earer\s+/, "") ?? ""; - if (!adminToken || !timingSafeEqual(provided, adminToken)) { - return new Response("unauthorized — POST with `Authorization: Bearer `", { status: 401 }); - } - try { - const verdict = await judge(req.params.owner, req.params.repo); - return Response.json(verdict); - } catch (err) { - return Response.json({ error: (err as Error).message }, { status: 500 }); - } - }, - - // Self-service visibility toggle. Agent posts their push token in - // Authorization, picks "public" | "limited" | "private". We verify - // the token actually belongs to :name by hitting Forgejo's /user - // endpoint with it, then PATCH the user via admin token. - "/api/agents/:name/visibility": async (req) => { - if (req.method !== "POST") return new Response("POST only", { status: 405 }); - const name = req.params.name; - const provided = req.headers.get("authorization")?.replace(/^[Bb]earer\s+/, "") ?? ""; - if (!provided) return Response.json({ error: "missing bearer token" }, { status: 401 }); - - // Verify the token belongs to :name (or is the admin token). - const adminToken = process.env.FORGEJO_ADMIN_TOKEN ?? ""; - let allowed = adminToken && timingSafeEqual(provided, adminToken); - if (!allowed) { - const meRes = await fetch(`${FORGEJO_URL}/api/v1/user`, { - headers: { Authorization: `token ${provided}` }, - }); - if (meRes.ok) { - const me = (await meRes.json()) as { login?: string }; - allowed = me.login === name; - } - } - if (!allowed) return Response.json({ error: "token does not match agent" }, { status: 403 }); - - let body: { visibility?: string }; - try { - body = (await req.json()) as { visibility?: string }; - } catch { - return Response.json({ error: "invalid json" }, { status: 400 }); - } - const visibility = body.visibility; - if (visibility !== "public" && visibility !== "limited" && visibility !== "private") { - return Response.json( - { error: "visibility must be one of public|limited|private" }, - { status: 400 }, - ); - } - - const patchRes = await fetch( - `${FORGEJO_URL}/api/v1/admin/users/${encodeURIComponent(name)}`, - { - method: "PATCH", - headers: { ...adminApiHeaders(), "Content-Type": "application/json" }, - body: JSON.stringify({ visibility, source_id: 0, login_name: name }), - }, - ); - if (!patchRes.ok) { - const text = await patchRes.text(); - return Response.json( - { error: `forgejo PATCH failed: ${patchRes.status} ${text}` }, - { status: 502 }, - ); - } - return Response.json({ name, visibility }); - }, - - "/api/forgejo/webhook": async (req) => { - if (req.method !== "POST") return new Response("POST only", { status: 405 }); - const secret = process.env.WEBHOOK_SECRET; - if (!secret) return new Response("webhook not configured", { status: 503 }); - - const body = await req.text(); - const provided = - req.headers.get("x-forgejo-signature") ?? req.headers.get("x-gitea-signature") ?? ""; - const expected = await hmacSha256Hex(secret, body); - if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) { - return new Response("invalid signature", { status: 401 }); - } - - let payload: { repository?: { owner?: { login?: string }; name?: string }; ref?: string }; - try { - payload = JSON.parse(body); - } catch { - return new Response("invalid json", { status: 400 }); - } - const owner = payload.repository?.owner?.login; - const repo = payload.repository?.name; - if (!owner || !repo) return new Response("missing owner/repo", { status: 400 }); - - // Fire the judge in the background; ack immediately so Forgejo - // doesn't time out while we're checking out commits. - void judge(owner, repo).catch((err) => { - console.error(`judge failed for ${owner}/${repo}:`, err); - }); - return Response.json({ accepted: true, owner, repo }); - }, + "/api/judge/:owner/:repo": judgeApiHandler, + "/api/agents/:name/visibility": agentVisibilityHandler, + "/api/forgejo/webhook": forgejoWebhookHandler, "/you": async (req) => { const viewer = await getViewer(req); diff --git a/src/c21_handlers_api_agents.ts b/src/c21_handlers_api_agents.ts new file mode 100644 index 0000000000000000000000000000000000000000..531f6ab906b109833517cf6787f74130601c78b4 --- /dev/null +++ b/src/c21_handlers_api_agents.ts @@ -0,0 +1,95 @@ +// c21 — handlers: agent-facing JSON API. Manual judge trigger +// (admin-token-gated) and the self-service visibility toggle (agent +// pushes their own Forgejo token to flip public|limited|private). +// Extracted from c21_app.ts per the SAMA Atomic rule. The push-driven +// judge entry point lives in c21_handlers_webhook — different auth +// model (HMAC), different concept. + +import { judge } from "./c32_judge.ts"; +import { timingSafeEqual } from "./c32_session.ts"; +import { + FORGEJO_URL, + adminApiHeaders, +} from "./c14_forgejo.ts"; + +export const judgeApiHandler = async ( + req: Request & { params: { owner: string; repo: string } }, +): Promise => { + if (req.method !== "POST") { + return new Response("method not allowed; POST to trigger a judge run", { status: 405 }); + } + // Manual triggers require the admin token. Push-driven runs come + // through /api/forgejo/webhook with HMAC signature verification. + const adminToken = process.env.FORGEJO_ADMIN_TOKEN; + const provided = req.headers.get("authorization")?.replace(/^[Bb]earer\s+/, "") ?? ""; + if (!adminToken || !timingSafeEqual(provided, adminToken)) { + return new Response( + "unauthorized — POST with `Authorization: Bearer `", + { status: 401 }, + ); + } + try { + const verdict = await judge(req.params.owner, req.params.repo); + return Response.json(verdict); + } catch (err) { + return Response.json({ error: (err as Error).message }, { status: 500 }); + } +}; + +// Self-service visibility toggle. Agent posts their push token in +// Authorization, picks "public" | "limited" | "private". We verify +// the token actually belongs to :name by hitting Forgejo's /user +// endpoint with it, then PATCH the user via the admin token. +export const agentVisibilityHandler = async ( + req: Request & { params: { name: string } }, +): Promise => { + if (req.method !== "POST") return new Response("POST only", { status: 405 }); + const name = req.params.name; + const provided = req.headers.get("authorization")?.replace(/^[Bb]earer\s+/, "") ?? ""; + if (!provided) return Response.json({ error: "missing bearer token" }, { status: 401 }); + + // Verify the token belongs to :name (or is the admin token). + const adminToken = process.env.FORGEJO_ADMIN_TOKEN ?? ""; + let allowed = !!adminToken && timingSafeEqual(provided, adminToken); + if (!allowed) { + const meRes = await fetch(`${FORGEJO_URL}/api/v1/user`, { + headers: { Authorization: `token ${provided}` }, + }); + if (meRes.ok) { + const me = (await meRes.json()) as { login?: string }; + allowed = me.login === name; + } + } + if (!allowed) return Response.json({ error: "token does not match agent" }, { status: 403 }); + + let body: { visibility?: string }; + try { + body = (await req.json()) as { visibility?: string }; + } catch { + return Response.json({ error: "invalid json" }, { status: 400 }); + } + const visibility = body.visibility; + if (visibility !== "public" && visibility !== "limited" && visibility !== "private") { + return Response.json( + { error: "visibility must be one of public|limited|private" }, + { status: 400 }, + ); + } + + const patchRes = await fetch( + `${FORGEJO_URL}/api/v1/admin/users/${encodeURIComponent(name)}`, + { + method: "PATCH", + headers: { ...adminApiHeaders(), "Content-Type": "application/json" }, + body: JSON.stringify({ visibility, source_id: 0, login_name: name }), + }, + ); + if (!patchRes.ok) { + const text = await patchRes.text(); + return Response.json( + { error: `forgejo PATCH failed: ${patchRes.status} ${text}` }, + { status: 502 }, + ); + } + return Response.json({ name, visibility }); +}; diff --git a/src/c21_handlers_fallback.ts b/src/c21_handlers_fallback.ts new file mode 100644 index 0000000000000000000000000000000000000000..63bf609e0d17f2bf40101cf033cfe8ffdb1ca919 --- /dev/null +++ b/src/c21_handlers_fallback.ts @@ -0,0 +1,131 @@ +// c21 — handlers: the Bun.serve `fetch` fallback. Catches every request +// the routes table can't express directly: regex-matched multi-segment +// slugs (admin edit/delete, /p/), the /GIT browse tree, the +// bare //.git redirect, the git smart/dumb-HTTP proxy, and +// the bare // repo view. Extracted from c21_app.ts per the +// SAMA Atomic rule. + +import { + renderNotFound, + htmlResponse, +} from "./c51_render_layout.ts"; +import { proxyToForgejo } from "./c14_forgejo.ts"; +import { getViewer } from "./c32_session.ts"; +import { renderRepoView } from "./c21_handlers_repo_view.ts"; +import { + adminEditHandler, + adminDeleteHandler, +} from "./c21_handlers_admin.ts"; +import { renderPublicPage } from "./c21_handlers_content.ts"; +import { + parseRepoBrowsePath, + repoBrowseHandler, +} from "./c21_handlers_repo_browse.ts"; + +const isGitProtocol = (pathname: string, search: URLSearchParams): boolean => { + if (pathname.includes(".git/") || pathname.endsWith(".git")) return true; + if ( + pathname.endsWith("/info/refs") && + (search.get("service") === "git-upload-pack" || search.get("service") === "git-receive-pack") + ) { + return true; + } + if (pathname.endsWith("/git-upload-pack") || pathname.endsWith("/git-receive-pack")) { + return true; + } + return false; +}; + +export const appFetch = async (req: Request): Promise => { + const url = new URL(req.url); + + // Admin edit/delete on multi-segment slugs (company/about, docs/spec/grammar + // etc.). Bun's `:slug` param can't span "/" so anything with two-or-more + // segments after the type slot ends up here. Single-segment is handled + // by the routes table and never reaches this branch. + const adminEditMulti = url.pathname.match( + /^\/admin\/edit\/(page|post)\/([a-z0-9_\-/]+?)\/?$/, + ); + if (adminEditMulti) { + const reqP = Object.assign(req, { + params: { type: adminEditMulti[1]!, slug: adminEditMulti[2]! }, + }); + return adminEditHandler(reqP); + } + const adminDeleteMulti = url.pathname.match( + /^\/admin\/delete\/(page|post)\/([a-z0-9_\-/]+?)\/?$/, + ); + if (adminDeleteMulti) { + const reqP = Object.assign(req, { + params: { type: adminDeleteMulti[1]!, slug: adminDeleteMulti[2]! }, + }); + return adminDeleteHandler(reqP); + } + + // Public sxdoc-backed pages on multi-segment slugs (e.g. + // /p/company/about, /p/docs/spec/grammar). Single-segment goes through + // the explicit `/p/:slug` route on Bun.serve. + const publicPageMulti = url.pathname.match(/^\/p\/([a-z0-9_\-/]+?)\/?$/); + if (publicPageMulti) { + return renderPublicPage(publicPageMulti[1]!); + } + + // Bare //.git (no sub-path) is what someone gets when + // they paste the clone URL into a browser. Without intervention our + // proxy hands it to Forgejo, whose chrome then leaks onto tdd.md. + // Redirect to the clean URL so the visitor lands on the Bun-native + // scoreboard. Real git operations always have sub-paths + // (/info/refs, /git-upload-pack, /objects/...) and continue to be + // proxied below. + const bareGitUrl = url.pathname.match( + /^\/([A-Za-z0-9][A-Za-z0-9-]*)\/([A-Za-z0-9][A-Za-z0-9._-]*)\.git\/?$/, + ); + if (bareGitUrl) { + return new Response(null, { + status: 302, + headers: { Location: `/${bareGitUrl[1]}/${bareGitUrl[2]}` }, + }); + } + + // SAMA-native repo browse at /GIT/:owner/:repo/{tree,blob,raw}/:ref/. + // The wildcard path needs more flexibility than Bun's :param routes + // give us (no slashes), so we match in the fallback fetch instead. + const gitBrowseMatch = url.pathname.match( + /^\/GIT\/([A-Za-z0-9][A-Za-z0-9._-]+)\/([A-Za-z0-9][A-Za-z0-9._-]+)\/(.+)$/, + ); + if (gitBrowseMatch) { + const owner = gitBrowseMatch[1]!; + const repo = gitBrowseMatch[2]!; + const suffix = gitBrowseMatch[3]!; + // Skip the commit/ shape — that's c21_handlers_commit_view's + // turf and lives as an explicit Bun.serve route in c21_app. + if (!suffix.startsWith("commit/")) { + const target = parseRepoBrowsePath(suffix); + if (target !== null) { + return repoBrowseHandler(req, owner, repo, target); + } + } + } + + // Git smart-HTTP and dumb-HTTP — proxy raw to Forgejo. + if (isGitProtocol(url.pathname, url.searchParams)) { + return proxyToForgejo(req, url.pathname + url.search); + } + + // Bare repo URL: // — render Bun-native view via Forgejo API. + // Two segments only, no trailing path. Reserved top-level paths are + // already matched by explicit routes in c21_app and never reach here. + const repoMatch = url.pathname.match(/^\/([A-Za-z0-9][A-Za-z0-9-]*)\/([A-Za-z0-9][A-Za-z0-9._-]*)\/?$/); + if (repoMatch) { + const viewer = await getViewer(req); + return renderRepoView(repoMatch[1]!, repoMatch[2]!, viewer); + } + + const html = await renderNotFound(url.pathname); + return htmlResponse(html, 404); +}; + +export const appError = (err: Error): Response => { + console.error(err); + return new Response("internal error", { status: 500 }); +}; diff --git a/src/c21_handlers_projects.ts b/src/c21_handlers_projects.ts new file mode 100644 index 0000000000000000000000000000000000000000..2f3d4738cdf8d0295272d093380e69fce065caa9 --- /dev/null +++ b/src/c21_handlers_projects.ts @@ -0,0 +1,114 @@ +// c21 — handlers: /projects cluster. Landing page lists every active +// project from the SQLite store, /projects/new accepts a `owner/repo` +// form (GitHub source-of-truth check + upsert), /projects/:owner/:name +// renders the per-project detail page. Extracted from c21_app.ts per +// the SAMA Atomic rule. + +import { + renderPage, + renderNotFound, + htmlResponse, +} from "./c51_render_layout.ts"; +import { + projectsLandingMd, + projectRegisterMd, + projectDetailMd, +} from "./c51_render_projects.ts"; +import { parseRepoIdentifier } from "./c31_project_config.ts"; +import { fetchProjectConfig } from "./c14_github.ts"; +import { + listActiveProjects, + getProject, + upsertProject, +} from "./c13_database.ts"; +import { getViewer } from "./c32_session.ts"; + +export const projectsLandingHandler = async (): Promise => { + const projects = listActiveProjects(); + const html = await renderPage({ + title: "Projects — tdd.md", + description: + "Real repos opted in to tdd.md scoring. Each project drops .tdd-md.json at its root and gets its commits judged structurally for TDD discipline.", + bodyMarkdown: projectsLandingMd(projects), + ogPath: "https://tdd.md/projects", + }); + return htmlResponse(html); +}; + +export const projectsNewHandler = async (req: Request): Promise => { + const viewer = await getViewer(req); + if (req.method === "GET") { + const url = new URL(req.url); + const prefilled = url.searchParams.get("repo") ?? undefined; + const html = await renderPage({ + title: "Register a project — tdd.md", + description: + "Onboard a real repo for TDD-discipline scoring. Drops .tdd-md.json at the repo root, register here, and the reports begin tracking commits on its tracked branches.", + bodyMarkdown: projectRegisterMd(viewer, prefilled), + ogPath: "https://tdd.md/projects/new", + noindex: true, + }); + return htmlResponse(html); + } + if (req.method !== "POST") return new Response("method not allowed", { status: 405 }); + if (!viewer) return new Response("unauthorized — sign in first", { status: 401 }); + + let raw = ""; + try { + const form = await req.formData(); + raw = String(form.get("repo") ?? "").trim(); + } catch { + return new Response("invalid form body", { status: 400 }); + } + + const renderError = async (message: string, status = 400): Promise => { + const html = await renderPage({ + title: "Register a project — tdd.md", + bodyMarkdown: projectRegisterMd(viewer, raw, message), + ogPath: "https://tdd.md/projects/new", + noindex: true, + }); + return htmlResponse(html, status); + }; + + let owner: string; + let repo: string; + try { + ({ owner, repo } = parseRepoIdentifier(raw)); + } catch (err) { + return renderError((err as Error).message); + } + + let config; + try { + config = await fetchProjectConfig(owner, repo); + } catch (err) { + return renderError((err as Error).message); + } + + upsertProject(viewer, owner, repo, config); + return new Response(null, { + status: 303, + headers: { Location: `/projects/${owner}/${repo}` }, + }); +}; + +export const projectDetailHandler = async ( + req: Request & { params: { repoOwner: string; repoName: string } }, +): Promise => { + const { repoOwner, repoName } = req.params; + const project = getProject(repoOwner, repoName); + if (!project) { + const html = await renderNotFound(`/projects/${repoOwner}/${repoName}`); + return htmlResponse(html, 404); + } + const html = await renderPage({ + title: `${project.displayName ?? `${project.repoOwner}/${project.repoName}`} — tdd.md`, + description: `${project.repoOwner}/${project.repoName} on tdd.md — ${ + project.testRunner === "none" ? "trace-mode" : project.testRunner + } judging across ${project.trackedBranches.join(", ")}.`, + bodyMarkdown: projectDetailMd(project), + ogPath: `https://tdd.md/projects/${project.repoOwner}/${project.repoName}`, + }); + return htmlResponse(html); +}; diff --git a/src/c21_handlers_webhook.ts b/src/c21_handlers_webhook.ts new file mode 100644 index 0000000000000000000000000000000000000000..0f1f3334c4d6946b6c61b63d0b7bfd1bc3548808 --- /dev/null +++ b/src/c21_handlers_webhook.ts @@ -0,0 +1,41 @@ +// c21 — handlers: Forgejo push-webhook entry point. HMAC-verified, fires +// `judge()` in the background and acks immediately so the upstream push +// hook doesn't time out while we're checking out commits. Extracted +// from c21_app.ts per the SAMA Atomic rule — separate file from the +// manual /api/judge trigger because the auth model (HMAC vs. bearer) +// and the failure semantics (ack-and-fire vs. wait-for-verdict) are +// genuinely different concepts. + +import { judge } from "./c32_judge.ts"; +import { timingSafeEqual, hmacSha256Hex } from "./c32_session.ts"; + +export const forgejoWebhookHandler = async (req: Request): Promise => { + if (req.method !== "POST") return new Response("POST only", { status: 405 }); + const secret = process.env.WEBHOOK_SECRET; + if (!secret) return new Response("webhook not configured", { status: 503 }); + + const body = await req.text(); + const provided = + req.headers.get("x-forgejo-signature") ?? req.headers.get("x-gitea-signature") ?? ""; + const expected = await hmacSha256Hex(secret, body); + if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) { + return new Response("invalid signature", { status: 401 }); + } + + let payload: { repository?: { owner?: { login?: string }; name?: string }; ref?: string }; + try { + payload = JSON.parse(body); + } catch { + return new Response("invalid json", { status: 400 }); + } + const owner = payload.repository?.owner?.login; + const repo = payload.repository?.name; + if (!owner || !repo) return new Response("missing owner/repo", { status: 400 }); + + // Fire the judge in the background; ack immediately so Forgejo + // doesn't time out while we're checking out commits. + void judge(owner, repo).catch((err) => { + console.error(`judge failed for ${owner}/${repo}:`, err); + }); + return Response.json({ accepted: true, owner, repo }); +};