syntaxai/tdd.md · commit c9e085a

SAMA Atomic: split c21_app.ts per-domain (fallback / projects / api / webhook)

The verifier flagged c21_app.ts at 761 LOC — over the 700-line Atomic
threshold. Following SAMA's own split-per-domain guidance:

- c21_handlers_fallback.ts — appFetch + isGitProtocol (regex-route
  fallback: multi-segment admin slugs, /GIT browse, git proxy, bare
  repo view, /<owner>/<repo>.git redirect)
- c21_handlers_projects.ts — /projects, /projects/new, /projects/:owner/:name
- c21_handlers_api_agents.ts — /api/judge + /api/agents/:name/visibility
  (manual bearer-token API)
- c21_handlers_webhook.ts — /api/forgejo/webhook (HMAC-verified push)

c21_app.ts: 761 → 452 LOC. Verifier now A:pass on all 67 SAMA files;
138/138 bun test green. Modeled violations on c32_judge/session/
real_reports/real_tests are pre-existing and out of scope.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
author
syntaxai <[email protected]>
date
2026-05-22 09:38:07 +01:00
parent
3cbd955
commit
c9e085af6f774c2a3239b94b4eaf4145bb722c48

5 files changed · +398 −325

modified src/c21_app.ts +17 −325
@@ -8,35 +8,14 @@ import {
88 htmlResponse,
99 } from "./c51_render_layout.ts";
1010 import { renderDocsPage } from "./c51_render_docs_layout.ts";
11-import {
12- projectsLandingMd,
13- projectRegisterMd,
14- projectDetailMd,
15-} from "./c51_render_projects.ts";
16-import {
17- FORGEJO_URL,
18- adminApiHeaders,
19- proxyToForgejo,
20-} from "./c14_forgejo.ts";
21-import { fetchProjectConfig } from "./c14_github.ts";
2211 import { listGames, loadGame } from "./c31_games.ts";
2312 import { ALL_POSTS } from "./c31_blog.ts";
2413 import { ALL_GUIDES } from "./c31_guides.ts";
2514 import { ALL_SAMA } from "./c31_sama.ts";
26-import { parseRepoIdentifier } from "./c31_project_config.ts";
27-import { judge } from "./c32_judge.ts";
2815 import {
2916 getViewer,
3017 sessionCookieHeader,
31- timingSafeEqual,
32- hmacSha256Hex,
3318 } from "./c32_session.ts";
34-import {
35- listActiveProjects,
36- getProject,
37- upsertProject,
38-} from "./c13_database.ts";
39-import { renderRepoView } from "./c21_handlers_repo_view.ts";
4019 import { renderAgentsIndex, renderAgentDetail } from "./c21_handlers_agents.ts";
4120 import { renderLeaderboard } from "./c21_handlers_leaderboard.ts";
4221 import { startGithubOauth, handleGithubCallback } from "./c21_handlers_auth.ts";
@@ -65,13 +44,20 @@ import {
6544 adminDeleteHandler,
6645 } from "./c21_handlers_admin.ts";
6746 import { bundleAdminClient } from "./c14_client_bundle.ts";
68-import { publicPageHandler, renderPublicPage } from "./c21_handlers_content.ts";
47+import { publicPageHandler } from "./c21_handlers_content.ts";
6948 import { rawSourceHandler } from "./c21_handlers_source.ts";
7049 import { commitViewHandler } from "./c21_handlers_commit_view.ts";
50+import { appFetch, appError } from "./c21_handlers_fallback.ts";
7151 import {
72- parseRepoBrowsePath,
73- repoBrowseHandler,
74-} from "./c21_handlers_repo_browse.ts";
52+ projectsLandingHandler,
53+ projectsNewHandler,
54+ projectDetailHandler,
55+} from "./c21_handlers_projects.ts";
56+import {
57+ judgeApiHandler,
58+ agentVisibilityHandler,
59+} from "./c21_handlers_api_agents.ts";
60+import { forgejoWebhookHandler } from "./c21_handlers_webhook.ts";
7561
7662 const HOME_MD = "./content/home.md";
7763 const GAME_DIR = "./content/games";
@@ -169,118 +155,6 @@ const REGISTER_HTML = await renderPage({
169155 noindex: true,
170156 });
171157
172-
173-const isGitProtocol = (pathname: string, search: URLSearchParams): boolean => {
174- if (pathname.includes(".git/") || pathname.endsWith(".git")) return true;
175- if (
176- pathname.endsWith("/info/refs") &&
177- (search.get("service") === "git-upload-pack" || search.get("service") === "git-receive-pack")
178- ) {
179- return true;
180- }
181- if (pathname.endsWith("/git-upload-pack") || pathname.endsWith("/git-receive-pack")) {
182- return true;
183- }
184- return false;
185-};
186-
187-// Fallback handler — git-protocol proxy, bare-repo /:owner/:repo view,
188-// admin multi-segment slugs, and /:owner/:repo.git redirects. Mounted as
189-// `fetch` on Bun.serve.
190-const appFetch = async (req: Request): Promise<Response> => {
191- const url = new URL(req.url);
192-
193- // Admin edit/delete on multi-segment slugs (company/about, docs/spec/grammar
194- // etc.). Bun's `:slug` param can't span "/" so anything with two-or-more
195- // segments after the type slot ends up here. Single-segment is handled
196- // by the routes table above and never reaches this branch.
197- const adminEditMulti = url.pathname.match(
198- /^\/admin\/edit\/(page|post)\/([a-z0-9_\-/]+?)\/?$/,
199- );
200- if (adminEditMulti) {
201- const reqP = Object.assign(req, {
202- params: { type: adminEditMulti[1]!, slug: adminEditMulti[2]! },
203- });
204- return adminEditHandler(reqP);
205- }
206- const adminDeleteMulti = url.pathname.match(
207- /^\/admin\/delete\/(page|post)\/([a-z0-9_\-/]+?)\/?$/,
208- );
209- if (adminDeleteMulti) {
210- const reqP = Object.assign(req, {
211- params: { type: adminDeleteMulti[1]!, slug: adminDeleteMulti[2]! },
212- });
213- return adminDeleteHandler(reqP);
214- }
215-
216- // Public sxdoc-backed pages on multi-segment slugs (e.g.
217- // /p/company/about, /p/docs/spec/grammar). Single-segment is handled
218- // by the routes table above.
219- const publicPageMulti = url.pathname.match(/^\/p\/([a-z0-9_\-/]+?)\/?$/);
220- if (publicPageMulti) {
221- return renderPublicPage(publicPageMulti[1]!);
222- }
223-
224- // Bare /<owner>/<repo>.git (no sub-path) is what someone gets when
225- // they paste the clone URL into a browser. Without intervention our
226- // proxy hands it to Forgejo, which renders its own repo page —
227- // Forgejo's chrome leaks onto tdd.md. Redirect to the clean URL
228- // so the visitor lands on our Bun-native scoreboard instead. Real
229- // git operations always have sub-paths (/info/refs, /git-upload-pack,
230- // /objects/...) and continue to be proxied below.
231- const bareGitUrl = url.pathname.match(
232- /^\/([A-Za-z0-9][A-Za-z0-9-]*)\/([A-Za-z0-9][A-Za-z0-9._-]*)\.git\/?$/,
233- );
234- if (bareGitUrl) {
235- return new Response(null, {
236- status: 302,
237- headers: { Location: `/${bareGitUrl[1]}/${bareGitUrl[2]}` },
238- });
239- }
240-
241- // SAMA-native repo browse at /GIT/:owner/:repo/{tree,blob,raw}/:ref/<path>.
242- // The wildcard path needs more flexibility than Bun's :param routes
243- // give us (no slashes), so we match in the fallback fetch instead.
244- const gitBrowseMatch = url.pathname.match(
245- /^\/GIT\/([A-Za-z0-9][A-Za-z0-9._-]+)\/([A-Za-z0-9][A-Za-z0-9._-]+)\/(.+)$/,
246- );
247- if (gitBrowseMatch) {
248- const owner = gitBrowseMatch[1]!;
249- const repo = gitBrowseMatch[2]!;
250- const suffix = gitBrowseMatch[3]!;
251- // Skip the commit/<sha> shape — that's c21_handlers_commit_view's
252- // turf and lives as an explicit Bun.serve route above.
253- if (!suffix.startsWith("commit/")) {
254- const target = parseRepoBrowsePath(suffix);
255- if (target !== null) {
256- return repoBrowseHandler(req, owner, repo, target);
257- }
258- }
259- }
260-
261- // Git smart-HTTP and dumb-HTTP — proxy raw to Forgejo.
262- if (isGitProtocol(url.pathname, url.searchParams)) {
263- return proxyToForgejo(req, url.pathname + url.search);
264- }
265-
266- // Bare repo URL: /<owner>/<repo> — render Bun-native view via Forgejo API.
267- // Two segments only, no trailing path. Reserved top-level paths are
268- // already matched by explicit routes above, so they never reach here.
269- const repoMatch = url.pathname.match(/^\/([A-Za-z0-9][A-Za-z0-9-]*)\/([A-Za-z0-9][A-Za-z0-9._-]*)\/?$/);
270- if (repoMatch) {
271- const viewer = await getViewer(req);
272- return renderRepoView(repoMatch[1]!, repoMatch[2]!, viewer);
273- }
274-
275- const html = await renderNotFound(url.pathname);
276- return htmlResponse(html, 404);
277-};
278-
279-const appError = (err: Error): Response => {
280- console.error(err);
281- return new Response("internal error", { status: 500 });
282-};
283-
284158 // ---------------------------------------------------------------------
285159 // App factory — c11 calls createApp(port) to start the server. The
286160 // routes literal stays inline here so Bun's path-parameter inference
@@ -416,89 +290,9 @@ ${rows}
416290 return htmlResponse(html);
417291 },
418292
419- "/projects": async () => {
420- const projects = listActiveProjects();
421- const html = await renderPage({
422- title: "Projects — tdd.md",
423- 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.",
424- bodyMarkdown: projectsLandingMd(projects),
425- ogPath: "https://tdd.md/projects",
426- });
427- return htmlResponse(html);
428- },
429-
430- "/projects/new": async (req) => {
431- const viewer = await getViewer(req);
432- if (req.method === "GET") {
433- const url = new URL(req.url);
434- const prefilled = url.searchParams.get("repo") ?? undefined;
435- const html = await renderPage({
436- title: "Register a project — tdd.md",
437- 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.",
438- bodyMarkdown: projectRegisterMd(viewer, prefilled),
439- ogPath: "https://tdd.md/projects/new",
440- noindex: true,
441- });
442- return htmlResponse(html);
443- }
444- if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
445- if (!viewer) return new Response("unauthorized — sign in first", { status: 401 });
446-
447- let raw = "";
448- try {
449- const form = await req.formData();
450- raw = String(form.get("repo") ?? "").trim();
451- } catch {
452- return new Response("invalid form body", { status: 400 });
453- }
454-
455- const renderError = async (message: string, status = 400): Promise<Response> => {
456- const html = await renderPage({
457- title: "Register a project — tdd.md",
458- bodyMarkdown: projectRegisterMd(viewer, raw, message),
459- ogPath: "https://tdd.md/projects/new",
460- noindex: true,
461- });
462- return htmlResponse(html, status);
463- };
464-
465- let owner: string;
466- let repo: string;
467- try {
468- ({ owner, repo } = parseRepoIdentifier(raw));
469- } catch (err) {
470- return renderError((err as Error).message);
471- }
472-
473- let config;
474- try {
475- config = await fetchProjectConfig(owner, repo);
476- } catch (err) {
477- return renderError((err as Error).message);
478- }
479-
480- upsertProject(viewer, owner, repo, config);
481- return new Response(null, {
482- status: 303,
483- headers: { Location: `/projects/${owner}/${repo}` },
484- });
485- },
486-
487- "/projects/:repoOwner/:repoName": async (req) => {
488- const { repoOwner, repoName } = req.params;
489- const project = getProject(repoOwner, repoName);
490- if (!project) {
491- const html = await renderNotFound(`/projects/${repoOwner}/${repoName}`);
492- return htmlResponse(html, 404);
493- }
494- const html = await renderPage({
495- title: `${project.displayName ?? `${project.repoOwner}/${project.repoName}`} — tdd.md`,
496- description: `${project.repoOwner}/${project.repoName} on tdd.md — ${project.testRunner === "none" ? "trace-mode" : project.testRunner} judging across ${project.trackedBranches.join(", ")}.`,
497- bodyMarkdown: projectDetailMd(project),
498- ogPath: `https://tdd.md/projects/${project.repoOwner}/${project.repoName}`,
499- });
500- return htmlResponse(html);
501- },
293+ "/projects": projectsLandingHandler,
294+ "/projects/new": projectsNewHandler,
295+ "/projects/:repoOwner/:repoName": projectDetailHandler,
502296
503297 "/reports": reportsLandingHandler,
504298 "/reports/demo": reportsDemoHandler,
@@ -592,111 +386,9 @@ ${rows}
592386
593387 "/leaderboard": () => renderLeaderboard(),
594388
595- "/api/judge/:owner/:repo": async (req) => {
596- if (req.method !== "POST") {
597- return new Response("method not allowed; POST to trigger a judge run", { status: 405 });
598- }
599- // Manual triggers require the admin token. Push-driven runs come
600- // through /api/forgejo/webhook with HMAC signature verification.
601- const adminToken = process.env.FORGEJO_ADMIN_TOKEN;
602- const provided = req.headers.get("authorization")?.replace(/^[Bb]earer\s+/, "") ?? "";
603- if (!adminToken || !timingSafeEqual(provided, adminToken)) {
604- return new Response("unauthorized — POST with `Authorization: Bearer <admin-token>`", { status: 401 });
605- }
606- try {
607- const verdict = await judge(req.params.owner, req.params.repo);
608- return Response.json(verdict);
609- } catch (err) {
610- return Response.json({ error: (err as Error).message }, { status: 500 });
611- }
612- },
613-
614- // Self-service visibility toggle. Agent posts their push token in
615- // Authorization, picks "public" | "limited" | "private". We verify
616- // the token actually belongs to :name by hitting Forgejo's /user
617- // endpoint with it, then PATCH the user via admin token.
618- "/api/agents/:name/visibility": async (req) => {
619- if (req.method !== "POST") return new Response("POST only", { status: 405 });
620- const name = req.params.name;
621- const provided = req.headers.get("authorization")?.replace(/^[Bb]earer\s+/, "") ?? "";
622- if (!provided) return Response.json({ error: "missing bearer token" }, { status: 401 });
623-
624- // Verify the token belongs to :name (or is the admin token).
625- const adminToken = process.env.FORGEJO_ADMIN_TOKEN ?? "";
626- let allowed = adminToken && timingSafeEqual(provided, adminToken);
627- if (!allowed) {
628- const meRes = await fetch(`${FORGEJO_URL}/api/v1/user`, {
629- headers: { Authorization: `token ${provided}` },
630- });
631- if (meRes.ok) {
632- const me = (await meRes.json()) as { login?: string };
633- allowed = me.login === name;
634- }
635- }
636- if (!allowed) return Response.json({ error: "token does not match agent" }, { status: 403 });
637-
638- let body: { visibility?: string };
639- try {
640- body = (await req.json()) as { visibility?: string };
641- } catch {
642- return Response.json({ error: "invalid json" }, { status: 400 });
643- }
644- const visibility = body.visibility;
645- if (visibility !== "public" && visibility !== "limited" && visibility !== "private") {
646- return Response.json(
647- { error: "visibility must be one of public|limited|private" },
648- { status: 400 },
649- );
650- }
651-
652- const patchRes = await fetch(
653- `${FORGEJO_URL}/api/v1/admin/users/${encodeURIComponent(name)}`,
654- {
655- method: "PATCH",
656- headers: { ...adminApiHeaders(), "Content-Type": "application/json" },
657- body: JSON.stringify({ visibility, source_id: 0, login_name: name }),
658- },
659- );
660- if (!patchRes.ok) {
661- const text = await patchRes.text();
662- return Response.json(
663- { error: `forgejo PATCH failed: ${patchRes.status} ${text}` },
664- { status: 502 },
665- );
666- }
667- return Response.json({ name, visibility });
668- },
669-
670- "/api/forgejo/webhook": async (req) => {
671- if (req.method !== "POST") return new Response("POST only", { status: 405 });
672- const secret = process.env.WEBHOOK_SECRET;
673- if (!secret) return new Response("webhook not configured", { status: 503 });
674-
675- const body = await req.text();
676- const provided =
677- req.headers.get("x-forgejo-signature") ?? req.headers.get("x-gitea-signature") ?? "";
678- const expected = await hmacSha256Hex(secret, body);
679- if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) {
680- return new Response("invalid signature", { status: 401 });
681- }
682-
683- let payload: { repository?: { owner?: { login?: string }; name?: string }; ref?: string };
684- try {
685- payload = JSON.parse(body);
686- } catch {
687- return new Response("invalid json", { status: 400 });
688- }
689- const owner = payload.repository?.owner?.login;
690- const repo = payload.repository?.name;
691- if (!owner || !repo) return new Response("missing owner/repo", { status: 400 });
692-
693- // Fire the judge in the background; ack immediately so Forgejo
694- // doesn't time out while we're checking out commits.
695- void judge(owner, repo).catch((err) => {
696- console.error(`judge failed for ${owner}/${repo}:`, err);
697- });
698- return Response.json({ accepted: true, owner, repo });
699- },
389+ "/api/judge/:owner/:repo": judgeApiHandler,
390+ "/api/agents/:name/visibility": agentVisibilityHandler,
391+ "/api/forgejo/webhook": forgejoWebhookHandler,
700392
701393 "/you": async (req) => {
702394 const viewer = await getViewer(req);
added src/c21_handlers_api_agents.ts +95 −0
@@ -0,0 +1,95 @@
1+// c21 — handlers: agent-facing JSON API. Manual judge trigger
2+// (admin-token-gated) and the self-service visibility toggle (agent
3+// pushes their own Forgejo token to flip public|limited|private).
4+// Extracted from c21_app.ts per the SAMA Atomic rule. The push-driven
5+// judge entry point lives in c21_handlers_webhook — different auth
6+// model (HMAC), different concept.
7+
8+import { judge } from "./c32_judge.ts";
9+import { timingSafeEqual } from "./c32_session.ts";
10+import {
11+ FORGEJO_URL,
12+ adminApiHeaders,
13+} from "./c14_forgejo.ts";
14+
15+export const judgeApiHandler = async (
16+ req: Request & { params: { owner: string; repo: string } },
17+): Promise<Response> => {
18+ if (req.method !== "POST") {
19+ return new Response("method not allowed; POST to trigger a judge run", { status: 405 });
20+ }
21+ // Manual triggers require the admin token. Push-driven runs come
22+ // through /api/forgejo/webhook with HMAC signature verification.
23+ const adminToken = process.env.FORGEJO_ADMIN_TOKEN;
24+ const provided = req.headers.get("authorization")?.replace(/^[Bb]earer\s+/, "") ?? "";
25+ if (!adminToken || !timingSafeEqual(provided, adminToken)) {
26+ return new Response(
27+ "unauthorized — POST with `Authorization: Bearer <admin-token>`",
28+ { status: 401 },
29+ );
30+ }
31+ try {
32+ const verdict = await judge(req.params.owner, req.params.repo);
33+ return Response.json(verdict);
34+ } catch (err) {
35+ return Response.json({ error: (err as Error).message }, { status: 500 });
36+ }
37+};
38+
39+// Self-service visibility toggle. Agent posts their push token in
40+// Authorization, picks "public" | "limited" | "private". We verify
41+// the token actually belongs to :name by hitting Forgejo's /user
42+// endpoint with it, then PATCH the user via the admin token.
43+export const agentVisibilityHandler = async (
44+ req: Request & { params: { name: string } },
45+): Promise<Response> => {
46+ if (req.method !== "POST") return new Response("POST only", { status: 405 });
47+ const name = req.params.name;
48+ const provided = req.headers.get("authorization")?.replace(/^[Bb]earer\s+/, "") ?? "";
49+ if (!provided) return Response.json({ error: "missing bearer token" }, { status: 401 });
50+
51+ // Verify the token belongs to :name (or is the admin token).
52+ const adminToken = process.env.FORGEJO_ADMIN_TOKEN ?? "";
53+ let allowed = !!adminToken && timingSafeEqual(provided, adminToken);
54+ if (!allowed) {
55+ const meRes = await fetch(`${FORGEJO_URL}/api/v1/user`, {
56+ headers: { Authorization: `token ${provided}` },
57+ });
58+ if (meRes.ok) {
59+ const me = (await meRes.json()) as { login?: string };
60+ allowed = me.login === name;
61+ }
62+ }
63+ if (!allowed) return Response.json({ error: "token does not match agent" }, { status: 403 });
64+
65+ let body: { visibility?: string };
66+ try {
67+ body = (await req.json()) as { visibility?: string };
68+ } catch {
69+ return Response.json({ error: "invalid json" }, { status: 400 });
70+ }
71+ const visibility = body.visibility;
72+ if (visibility !== "public" && visibility !== "limited" && visibility !== "private") {
73+ return Response.json(
74+ { error: "visibility must be one of public|limited|private" },
75+ { status: 400 },
76+ );
77+ }
78+
79+ const patchRes = await fetch(
80+ `${FORGEJO_URL}/api/v1/admin/users/${encodeURIComponent(name)}`,
81+ {
82+ method: "PATCH",
83+ headers: { ...adminApiHeaders(), "Content-Type": "application/json" },
84+ body: JSON.stringify({ visibility, source_id: 0, login_name: name }),
85+ },
86+ );
87+ if (!patchRes.ok) {
88+ const text = await patchRes.text();
89+ return Response.json(
90+ { error: `forgejo PATCH failed: ${patchRes.status} ${text}` },
91+ { status: 502 },
92+ );
93+ }
94+ return Response.json({ name, visibility });
95+};
added src/c21_handlers_fallback.ts +131 −0
@@ -0,0 +1,131 @@
1+// c21 — handlers: the Bun.serve `fetch` fallback. Catches every request
2+// the routes table can't express directly: regex-matched multi-segment
3+// slugs (admin edit/delete, /p/<deep/slug>), the /GIT browse tree, the
4+// bare /<owner>/<repo>.git redirect, the git smart/dumb-HTTP proxy, and
5+// the bare /<owner>/<repo> repo view. Extracted from c21_app.ts per the
6+// SAMA Atomic rule.
7+
8+import {
9+ renderNotFound,
10+ htmlResponse,
11+} from "./c51_render_layout.ts";
12+import { proxyToForgejo } from "./c14_forgejo.ts";
13+import { getViewer } from "./c32_session.ts";
14+import { renderRepoView } from "./c21_handlers_repo_view.ts";
15+import {
16+ adminEditHandler,
17+ adminDeleteHandler,
18+} from "./c21_handlers_admin.ts";
19+import { renderPublicPage } from "./c21_handlers_content.ts";
20+import {
21+ parseRepoBrowsePath,
22+ repoBrowseHandler,
23+} from "./c21_handlers_repo_browse.ts";
24+
25+const isGitProtocol = (pathname: string, search: URLSearchParams): boolean => {
26+ if (pathname.includes(".git/") || pathname.endsWith(".git")) return true;
27+ if (
28+ pathname.endsWith("/info/refs") &&
29+ (search.get("service") === "git-upload-pack" || search.get("service") === "git-receive-pack")
30+ ) {
31+ return true;
32+ }
33+ if (pathname.endsWith("/git-upload-pack") || pathname.endsWith("/git-receive-pack")) {
34+ return true;
35+ }
36+ return false;
37+};
38+
39+export const appFetch = async (req: Request): Promise<Response> => {
40+ const url = new URL(req.url);
41+
42+ // Admin edit/delete on multi-segment slugs (company/about, docs/spec/grammar
43+ // etc.). Bun's `:slug` param can't span "/" so anything with two-or-more
44+ // segments after the type slot ends up here. Single-segment is handled
45+ // by the routes table and never reaches this branch.
46+ const adminEditMulti = url.pathname.match(
47+ /^\/admin\/edit\/(page|post)\/([a-z0-9_\-/]+?)\/?$/,
48+ );
49+ if (adminEditMulti) {
50+ const reqP = Object.assign(req, {
51+ params: { type: adminEditMulti[1]!, slug: adminEditMulti[2]! },
52+ });
53+ return adminEditHandler(reqP);
54+ }
55+ const adminDeleteMulti = url.pathname.match(
56+ /^\/admin\/delete\/(page|post)\/([a-z0-9_\-/]+?)\/?$/,
57+ );
58+ if (adminDeleteMulti) {
59+ const reqP = Object.assign(req, {
60+ params: { type: adminDeleteMulti[1]!, slug: adminDeleteMulti[2]! },
61+ });
62+ return adminDeleteHandler(reqP);
63+ }
64+
65+ // Public sxdoc-backed pages on multi-segment slugs (e.g.
66+ // /p/company/about, /p/docs/spec/grammar). Single-segment goes through
67+ // the explicit `/p/:slug` route on Bun.serve.
68+ const publicPageMulti = url.pathname.match(/^\/p\/([a-z0-9_\-/]+?)\/?$/);
69+ if (publicPageMulti) {
70+ return renderPublicPage(publicPageMulti[1]!);
71+ }
72+
73+ // Bare /<owner>/<repo>.git (no sub-path) is what someone gets when
74+ // they paste the clone URL into a browser. Without intervention our
75+ // proxy hands it to Forgejo, whose chrome then leaks onto tdd.md.
76+ // Redirect to the clean URL so the visitor lands on the Bun-native
77+ // scoreboard. Real git operations always have sub-paths
78+ // (/info/refs, /git-upload-pack, /objects/...) and continue to be
79+ // proxied below.
80+ const bareGitUrl = url.pathname.match(
81+ /^\/([A-Za-z0-9][A-Za-z0-9-]*)\/([A-Za-z0-9][A-Za-z0-9._-]*)\.git\/?$/,
82+ );
83+ if (bareGitUrl) {
84+ return new Response(null, {
85+ status: 302,
86+ headers: { Location: `/${bareGitUrl[1]}/${bareGitUrl[2]}` },
87+ });
88+ }
89+
90+ // SAMA-native repo browse at /GIT/:owner/:repo/{tree,blob,raw}/:ref/<path>.
91+ // The wildcard path needs more flexibility than Bun's :param routes
92+ // give us (no slashes), so we match in the fallback fetch instead.
93+ const gitBrowseMatch = url.pathname.match(
94+ /^\/GIT\/([A-Za-z0-9][A-Za-z0-9._-]+)\/([A-Za-z0-9][A-Za-z0-9._-]+)\/(.+)$/,
95+ );
96+ if (gitBrowseMatch) {
97+ const owner = gitBrowseMatch[1]!;
98+ const repo = gitBrowseMatch[2]!;
99+ const suffix = gitBrowseMatch[3]!;
100+ // Skip the commit/<sha> shape — that's c21_handlers_commit_view's
101+ // turf and lives as an explicit Bun.serve route in c21_app.
102+ if (!suffix.startsWith("commit/")) {
103+ const target = parseRepoBrowsePath(suffix);
104+ if (target !== null) {
105+ return repoBrowseHandler(req, owner, repo, target);
106+ }
107+ }
108+ }
109+
110+ // Git smart-HTTP and dumb-HTTP — proxy raw to Forgejo.
111+ if (isGitProtocol(url.pathname, url.searchParams)) {
112+ return proxyToForgejo(req, url.pathname + url.search);
113+ }
114+
115+ // Bare repo URL: /<owner>/<repo> — render Bun-native view via Forgejo API.
116+ // Two segments only, no trailing path. Reserved top-level paths are
117+ // already matched by explicit routes in c21_app and never reach here.
118+ const repoMatch = url.pathname.match(/^\/([A-Za-z0-9][A-Za-z0-9-]*)\/([A-Za-z0-9][A-Za-z0-9._-]*)\/?$/);
119+ if (repoMatch) {
120+ const viewer = await getViewer(req);
121+ return renderRepoView(repoMatch[1]!, repoMatch[2]!, viewer);
122+ }
123+
124+ const html = await renderNotFound(url.pathname);
125+ return htmlResponse(html, 404);
126+};
127+
128+export const appError = (err: Error): Response => {
129+ console.error(err);
130+ return new Response("internal error", { status: 500 });
131+};
added src/c21_handlers_projects.ts +114 −0
@@ -0,0 +1,114 @@
1+// c21 — handlers: /projects cluster. Landing page lists every active
2+// project from the SQLite store, /projects/new accepts a `owner/repo`
3+// form (GitHub source-of-truth check + upsert), /projects/:owner/:name
4+// renders the per-project detail page. Extracted from c21_app.ts per
5+// the SAMA Atomic rule.
6+
7+import {
8+ renderPage,
9+ renderNotFound,
10+ htmlResponse,
11+} from "./c51_render_layout.ts";
12+import {
13+ projectsLandingMd,
14+ projectRegisterMd,
15+ projectDetailMd,
16+} from "./c51_render_projects.ts";
17+import { parseRepoIdentifier } from "./c31_project_config.ts";
18+import { fetchProjectConfig } from "./c14_github.ts";
19+import {
20+ listActiveProjects,
21+ getProject,
22+ upsertProject,
23+} from "./c13_database.ts";
24+import { getViewer } from "./c32_session.ts";
25+
26+export const projectsLandingHandler = async (): Promise<Response> => {
27+ const projects = listActiveProjects();
28+ const html = await renderPage({
29+ title: "Projects — tdd.md",
30+ description:
31+ "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.",
32+ bodyMarkdown: projectsLandingMd(projects),
33+ ogPath: "https://tdd.md/projects",
34+ });
35+ return htmlResponse(html);
36+};
37+
38+export const projectsNewHandler = async (req: Request): Promise<Response> => {
39+ const viewer = await getViewer(req);
40+ if (req.method === "GET") {
41+ const url = new URL(req.url);
42+ const prefilled = url.searchParams.get("repo") ?? undefined;
43+ const html = await renderPage({
44+ title: "Register a project — tdd.md",
45+ description:
46+ "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.",
47+ bodyMarkdown: projectRegisterMd(viewer, prefilled),
48+ ogPath: "https://tdd.md/projects/new",
49+ noindex: true,
50+ });
51+ return htmlResponse(html);
52+ }
53+ if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
54+ if (!viewer) return new Response("unauthorized — sign in first", { status: 401 });
55+
56+ let raw = "";
57+ try {
58+ const form = await req.formData();
59+ raw = String(form.get("repo") ?? "").trim();
60+ } catch {
61+ return new Response("invalid form body", { status: 400 });
62+ }
63+
64+ const renderError = async (message: string, status = 400): Promise<Response> => {
65+ const html = await renderPage({
66+ title: "Register a project — tdd.md",
67+ bodyMarkdown: projectRegisterMd(viewer, raw, message),
68+ ogPath: "https://tdd.md/projects/new",
69+ noindex: true,
70+ });
71+ return htmlResponse(html, status);
72+ };
73+
74+ let owner: string;
75+ let repo: string;
76+ try {
77+ ({ owner, repo } = parseRepoIdentifier(raw));
78+ } catch (err) {
79+ return renderError((err as Error).message);
80+ }
81+
82+ let config;
83+ try {
84+ config = await fetchProjectConfig(owner, repo);
85+ } catch (err) {
86+ return renderError((err as Error).message);
87+ }
88+
89+ upsertProject(viewer, owner, repo, config);
90+ return new Response(null, {
91+ status: 303,
92+ headers: { Location: `/projects/${owner}/${repo}` },
93+ });
94+};
95+
96+export const projectDetailHandler = async (
97+ req: Request & { params: { repoOwner: string; repoName: string } },
98+): Promise<Response> => {
99+ const { repoOwner, repoName } = req.params;
100+ const project = getProject(repoOwner, repoName);
101+ if (!project) {
102+ const html = await renderNotFound(`/projects/${repoOwner}/${repoName}`);
103+ return htmlResponse(html, 404);
104+ }
105+ const html = await renderPage({
106+ title: `${project.displayName ?? `${project.repoOwner}/${project.repoName}`} — tdd.md`,
107+ description: `${project.repoOwner}/${project.repoName} on tdd.md — ${
108+ project.testRunner === "none" ? "trace-mode" : project.testRunner
109+ } judging across ${project.trackedBranches.join(", ")}.`,
110+ bodyMarkdown: projectDetailMd(project),
111+ ogPath: `https://tdd.md/projects/${project.repoOwner}/${project.repoName}`,
112+ });
113+ return htmlResponse(html);
114+};
added src/c21_handlers_webhook.ts +41 −0
@@ -0,0 +1,41 @@
1+// c21 — handlers: Forgejo push-webhook entry point. HMAC-verified, fires
2+// `judge()` in the background and acks immediately so the upstream push
3+// hook doesn't time out while we're checking out commits. Extracted
4+// from c21_app.ts per the SAMA Atomic rule — separate file from the
5+// manual /api/judge trigger because the auth model (HMAC vs. bearer)
6+// and the failure semantics (ack-and-fire vs. wait-for-verdict) are
7+// genuinely different concepts.
8+
9+import { judge } from "./c32_judge.ts";
10+import { timingSafeEqual, hmacSha256Hex } from "./c32_session.ts";
11+
12+export const forgejoWebhookHandler = async (req: Request): Promise<Response> => {
13+ if (req.method !== "POST") return new Response("POST only", { status: 405 });
14+ const secret = process.env.WEBHOOK_SECRET;
15+ if (!secret) return new Response("webhook not configured", { status: 503 });
16+
17+ const body = await req.text();
18+ const provided =
19+ req.headers.get("x-forgejo-signature") ?? req.headers.get("x-gitea-signature") ?? "";
20+ const expected = await hmacSha256Hex(secret, body);
21+ if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) {
22+ return new Response("invalid signature", { status: 401 });
23+ }
24+
25+ let payload: { repository?: { owner?: { login?: string }; name?: string }; ref?: string };
26+ try {
27+ payload = JSON.parse(body);
28+ } catch {
29+ return new Response("invalid json", { status: 400 });
30+ }
31+ const owner = payload.repository?.owner?.login;
32+ const repo = payload.repository?.name;
33+ if (!owner || !repo) return new Response("missing owner/repo", { status: 400 });
34+
35+ // Fire the judge in the background; ack immediately so Forgejo
36+ // doesn't time out while we're checking out commits.
37+ void judge(owner, repo).catch((err) => {
38+ console.error(`judge failed for ${owner}/${repo}:`, err);
39+ });
40+ return Response.json({ accepted: true, owner, repo });
41+};