syntaxai/tdd.md · commit a7a8d04

Self-hosted editor: replace "edit on GitHub" with proposal queue

The docs-layout edit link no longer points at GitHub's web editor. It
now points at /edit/<section>/<slug> on tdd.md itself. Authentication
still goes through GitHub OAuth (we already had that flow), but every
edit lands as a *proposal* in our SQLite store. Live pages do not
change until the owner downloads the proposed body via /admin/
proposals/<id>/patch and commits it on dev — keeping git as the
single source of truth and the deploy pipeline as the only path to
production. No file mutation in the running container.

  src/c31_site_config.ts (extended)
    ADMIN_USERNAME (gates /admin/*)
    SELF_HOSTED_REPO_BLOB_BASE (the "view source" link target)

  src/c13_database.ts (extended)
    proposals table + index. CRUD: createProposal, getProposal,
    listProposals (optional status filter), setProposalStatus.

  src/c31_proposals.ts (new)
  src/c31_proposals.test.ts (sibling test, 8 cases)
    Validates form-submitted proposal bodies before they reach the DB.
    256 KB body cap, non-empty body and author required. isNoOpProposal
    helper so the handler can skip storing identical resubmissions.

  src/c32_edit_resolve.ts (new)
  src/c32_edit_resolve.test.ts (sibling test, 7 cases)
    Pure resolver: (section, slug) -> { pageUrl, filePath, title }.
    Section must be one of sama/guides/blog. Slug must match an entry
    in the corresponding registry. Rejects path traversal, uppercase,
    leading dashes, etc. — locks edit targets to known doc pages so
    /edit/../../etc/passwd cannot escape the content tree.

  src/c21_handlers_auth.ts
    startGithubOauth(req) accepts an optional ?to=<path> query and
    sets a tdd_oauth_return cookie alongside the state nonce. The
    callback honours it for returning users (new-user welcome flow
    is unchanged). Open-redirect guard via isSafeReturnTo.

  src/c21_handlers_edit.ts (new)
    GET /edit/:section/:slug — login wall when anonymous, edit form
                               when signed in (current body in textarea)
    POST /edit/:section/:slug — validates, no-op-skips, createProposal,
                                redirects to thank-you page

  src/c21_handlers_admin.ts (new)
    GET /admin/proposals             - queue listing
    GET /admin/proposals/:id         - side-by-side current vs proposed
    POST /admin/proposals/:id/approve - mark approved (no fs write)
    POST /admin/proposals/:id/reject  - mark rejected with optional reason
    GET /admin/proposals/:id/patch    - downloadable .md with header
                                        comment for git apply
    All gated by viewer === ADMIN_USERNAME.

  src/c51_render_edit.ts (new)
    renderEditFormPage, renderEditLoginWall, renderEditThanks,
    renderAdminProposalList, renderAdminProposalDetail, renderAdminGate.
    Uses bodyHtml mode (no marked.parse) so <form> elements render
    instead of being escaped.

  src/c51_render_docs_layout.ts
    renderEditLink now emits two anchors: "propose an edit ->" linking
    to the internal /edit route, and "view source on git.tdd.md ->"
    linking to the self-hosted Forgejo mirror. The GitHub web-editor
    link is gone.

  src/c21_app.ts
    /edit/:section/:slug + 5 /admin/proposals/* routes registered as
    one-line delegations. /auth/github/start now passes req through
    so the returnTo query can be read.

  public/style.css
    Editor + admin styling: textarea, login button, status badges,
    side-by-side diff. Mobile collapses the diff to single column.

Verifier dogfood unchanged from before (S, A, Atomic still pass; the
4 c32_* sibling-test gaps stay queued as a separate sliver). c21_app.ts
went 649 -> 665 lines, comfortably under the 700 split threshold.

End-to-end verified: /edit/sama/sorted returns 401 with login wall;
/edit/sama/nonexistent returns 404 from resolveEdit; /admin/proposals
returns 401 for anon and the gate page renders; /sama/sorted now
shows the new "propose an edit" + "view source on git.tdd.md" links
with no GitHub edit-URL anywhere.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
author
syntaxai <[email protected]>
date
2026-05-10 09:37:19 +01:00
parent
8e72c3b
commit
a7a8d04441d1a356b0ac9ce91c9e314fe595bfd3

13 files changed · +966 −16

modified public/style.css +162 −0
@@ -699,3 +699,165 @@ main.md table.test-stability td.test-stab-num {
699699 margin-bottom: 1.5rem;
700700 }
701701 }
702+
703+/* -----------------------------------------------------------------
704+ Editor pages — /edit/:section/:slug, /admin/proposals/*. Wider
705+ content area, full-width form controls, side-by-side diff blocks.
706+----------------------------------------------------------------- */
707+
708+.edit-body main.md {
709+ max-width: none;
710+ padding: 0;
711+}
712+
713+.edit-container {
714+ max-width: 1100px;
715+ margin: 0 auto;
716+ padding: 1rem 1.5rem 4rem;
717+}
718+
719+.edit-meta {
720+ color: var(--muted);
721+ font-size: 0.9rem;
722+}
723+
724+.edit-form { display: flex; flex-direction: column; gap: 0.8rem; }
725+.edit-textarea {
726+ width: 100%;
727+ font-family: ui-monospace, "SF Mono", "JetBrains Mono", "Fira Code", Menlo, Consolas, monospace;
728+ font-size: 0.92rem;
729+ line-height: 1.55;
730+ padding: 0.8rem 1rem;
731+ background: color-mix(in srgb, var(--muted) 6%, transparent);
732+ color: var(--fg);
733+ border: 1px solid color-mix(in srgb, var(--muted) 30%, transparent);
734+ border-radius: 4px;
735+ resize: vertical;
736+ outline: none;
737+}
738+.edit-textarea:focus { border-color: var(--accent); }
739+
740+.edit-actions { display: flex; gap: 0.8rem; align-items: center; }
741+.edit-actions-row { flex-wrap: wrap; }
742+.edit-actions button,
743+.edit-form-inline button {
744+ font: inherit;
745+ font-family: ui-monospace, "SF Mono", "JetBrains Mono", "Fira Code", Menlo, Consolas, monospace;
746+ font-size: 0.88rem;
747+ padding: 0.55rem 1rem;
748+ background: var(--accent);
749+ color: var(--bg);
750+ border: 1px solid var(--accent);
751+ border-radius: 4px;
752+ cursor: pointer;
753+}
754+.edit-actions button:hover,
755+.edit-form-inline button:hover { filter: brightness(1.1); }
756+.edit-cancel,
757+.edit-action-link {
758+ color: var(--muted);
759+ text-decoration: none;
760+ font-size: 0.9rem;
761+}
762+.edit-cancel:hover, .edit-action-link:hover { color: var(--accent); }
763+
764+.edit-form-inline {
765+ display: inline-flex;
766+ gap: 0.4rem;
767+ align-items: center;
768+}
769+.edit-form-inline input[type="text"] {
770+ font-family: inherit;
771+ padding: 0.45rem 0.7rem;
772+ background: color-mix(in srgb, var(--muted) 6%, transparent);
773+ color: var(--fg);
774+ border: 1px solid color-mix(in srgb, var(--muted) 30%, transparent);
775+ border-radius: 4px;
776+ font-size: 0.85rem;
777+ min-width: 16rem;
778+}
779+
780+.edit-login-button {
781+ display: inline-block;
782+ padding: 0.7rem 1.2rem;
783+ background: var(--accent);
784+ color: var(--bg);
785+ text-decoration: none;
786+ border-radius: 4px;
787+ font-weight: 500;
788+}
789+.edit-login-button:hover { filter: brightness(1.1); }
790+
791+.edit-note {
792+ margin-top: 1.2rem;
793+ color: var(--muted);
794+ font-size: 0.88rem;
795+ border-left: 2px solid color-mix(in srgb, var(--muted) 30%, transparent);
796+ padding-left: 0.8rem;
797+}
798+
799+.edit-table {
800+ width: 100%;
801+ border-collapse: collapse;
802+ margin: 1rem 0 1.5rem;
803+}
804+.edit-table th, .edit-table td {
805+ text-align: left;
806+ padding: 0.55rem 0.8rem;
807+ border-bottom: 1px solid color-mix(in srgb, var(--muted) 18%, transparent);
808+ font-size: 0.9rem;
809+}
810+.edit-table th {
811+ font-weight: 600;
812+ color: var(--muted);
813+ text-transform: uppercase;
814+ font-size: 0.72rem;
815+ letter-spacing: 0.05em;
816+}
817+.edit-empty { color: var(--muted); text-align: center; padding: 2rem !important; }
818+
819+.edit-status {
820+ font-size: 0.78rem;
821+ padding: 0.15rem 0.55rem;
822+ border-radius: 999px;
823+ font-weight: 500;
824+ text-transform: uppercase;
825+ letter-spacing: 0.04em;
826+}
827+.edit-status-pending { background: color-mix(in srgb, var(--accent) 18%, transparent); color: var(--accent); }
828+.edit-status-approved { background: color-mix(in srgb, var(--green) 18%, transparent); color: var(--green); }
829+.edit-status-rejected { background: color-mix(in srgb, var(--red) 18%, transparent); color: var(--red); }
830+
831+.edit-diff {
832+ display: grid;
833+ grid-template-columns: 1fr 1fr;
834+ gap: 1rem;
835+ margin: 1rem 0 2rem;
836+}
837+.edit-diff-pane {
838+ border: 1px solid color-mix(in srgb, var(--muted) 25%, transparent);
839+ border-radius: 4px;
840+ background: color-mix(in srgb, var(--muted) 5%, transparent);
841+}
842+.edit-diff-label {
843+ margin: 0;
844+ padding: 0.5rem 0.8rem;
845+ font-size: 0.75rem;
846+ text-transform: uppercase;
847+ letter-spacing: 0.06em;
848+ color: var(--muted);
849+ border-bottom: 1px solid color-mix(in srgb, var(--muted) 25%, transparent);
850+}
851+.edit-diff-pane pre {
852+ margin: 0;
853+ padding: 0.8rem;
854+ max-height: 60vh;
855+ overflow: auto;
856+ font-size: 0.82rem;
857+ line-height: 1.5;
858+ white-space: pre-wrap;
859+}
860+
861+@media (max-width: 900px) {
862+ .edit-diff { grid-template-columns: 1fr; }
863+}
modified src/c13_database.ts +122 −0
@@ -35,6 +35,22 @@ const getDb = (): Database => {
3535 );
3636 CREATE INDEX IF NOT EXISTS idx_projects_registered_by
3737 ON projects(registered_by);
38+
39+ CREATE TABLE IF NOT EXISTS proposals (
40+ id INTEGER PRIMARY KEY AUTOINCREMENT,
41+ page_url TEXT NOT NULL,
42+ edit_path TEXT NOT NULL,
43+ title TEXT NOT NULL,
44+ body TEXT NOT NULL,
45+ author TEXT NOT NULL,
46+ submitted_at INTEGER NOT NULL,
47+ status TEXT NOT NULL DEFAULT 'pending',
48+ reviewed_at INTEGER,
49+ reviewed_by TEXT,
50+ reject_reason TEXT
51+ );
52+ CREATE INDEX IF NOT EXISTS idx_proposals_status
53+ ON proposals(status, submitted_at DESC);
3854 `);
3955 return db;
4056 };
@@ -194,6 +210,112 @@ export const listActiveProjects = (): ProjectRow[] => {
194210 return rows.map(rowToProject);
195211 };
196212
213+// ---------------------------------------------------------------------
214+// Proposals — page-edit suggestions submitted via the self-hosted
215+// editor. Stored pending until the admin approves or rejects them.
216+// Approved proposals never auto-mutate the live page; they're
217+// downloaded as a patch and committed by the owner manually, which is
218+// the "edit doesn't immediately replace live" guarantee.
219+// ---------------------------------------------------------------------
220+
221+export type ProposalStatus = "pending" | "approved" | "rejected";
222+
223+export interface ProposalRow {
224+ id: number;
225+ pageUrl: string;
226+ editPath: string;
227+ title: string;
228+ body: string;
229+ author: string;
230+ submittedAt: number;
231+ status: ProposalStatus;
232+ reviewedAt: number | null;
233+ reviewedBy: string | null;
234+ rejectReason: string | null;
235+}
236+
237+interface ProposalDbRow {
238+ id: number;
239+ page_url: string;
240+ edit_path: string;
241+ title: string;
242+ body: string;
243+ author: string;
244+ submitted_at: number;
245+ status: string;
246+ reviewed_at: number | null;
247+ reviewed_by: string | null;
248+ reject_reason: string | null;
249+}
250+
251+const rowToProposal = (r: ProposalDbRow): ProposalRow => {
252+ const status: ProposalStatus = r.status === "approved" || r.status === "rejected" ? r.status : "pending";
253+ return {
254+ id: r.id,
255+ pageUrl: r.page_url,
256+ editPath: r.edit_path,
257+ title: r.title,
258+ body: r.body,
259+ author: r.author,
260+ submittedAt: r.submitted_at,
261+ status,
262+ reviewedAt: r.reviewed_at,
263+ reviewedBy: r.reviewed_by,
264+ rejectReason: r.reject_reason,
265+ };
266+};
267+
268+export interface NewProposal {
269+ pageUrl: string;
270+ editPath: string;
271+ title: string;
272+ body: string;
273+ author: string;
274+}
275+
276+export const createProposal = (p: NewProposal): number => {
277+ const result = getDb().run(
278+ `INSERT INTO proposals (page_url, edit_path, title, body, author, submitted_at)
279+ VALUES (?, ?, ?, ?, ?, ?)`,
280+ [p.pageUrl, p.editPath, p.title, p.body, p.author, Date.now()],
281+ );
282+ return Number(result.lastInsertRowid);
283+};
284+
285+export const getProposal = (id: number): ProposalRow | null => {
286+ const row = getDb()
287+ .query<ProposalDbRow, [number]>(`SELECT * FROM proposals WHERE id = ?`)
288+ .get(id);
289+ return row ? rowToProposal(row) : null;
290+};
291+
292+export const listProposals = (status?: ProposalStatus): ProposalRow[] => {
293+ if (status) {
294+ return getDb()
295+ .query<ProposalDbRow, [string]>(
296+ `SELECT * FROM proposals WHERE status = ? ORDER BY submitted_at DESC`,
297+ )
298+ .all(status)
299+ .map(rowToProposal);
300+ }
301+ return getDb()
302+ .query<ProposalDbRow, []>(`SELECT * FROM proposals ORDER BY submitted_at DESC LIMIT 200`)
303+ .all()
304+ .map(rowToProposal);
305+};
306+
307+export const setProposalStatus = (
308+ id: number,
309+ status: ProposalStatus,
310+ reviewer: string,
311+ rejectReason: string | null = null,
312+): void => {
313+ getDb().run(
314+ `UPDATE proposals SET status = ?, reviewed_at = ?, reviewed_by = ?, reject_reason = ? WHERE id = ?`,
315+ [status, Date.now(), reviewer, rejectReason, id],
316+ );
317+};
318+
197319 // Latest verdict per (owner, repo) across all agents — drives the
198320 // leaderboard and the /agents index.
199321 export const allLatestRuns = (): { owner: string; repo: string; verdict: Verdict }[] => {
modified src/c21_app.ts +17 −1
@@ -57,6 +57,14 @@ import {
5757 samaLandingHandler,
5858 samaSlugHandler,
5959 } from "./c21_handlers_sama.ts";
60+import { editPageHandler } from "./c21_handlers_edit.ts";
61+import {
62+ adminProposalsHandler,
63+ adminProposalDetailHandler,
64+ adminProposalApproveHandler,
65+ adminProposalRejectHandler,
66+ adminProposalPatchHandler,
67+} from "./c21_handlers_admin.ts";
6068
6169 const HOME_MD = "./content/home.md";
6270 const GAME_DIR = "./content/games";
@@ -641,7 +649,15 @@ ${rows}
641649 });
642650 },
643651
644- "/auth/github/start": (_req) => startGithubOauth(),
652+ "/edit/:section/:slug": editPageHandler,
653+
654+ "/admin/proposals": adminProposalsHandler,
655+ "/admin/proposals/:id": adminProposalDetailHandler,
656+ "/admin/proposals/:id/approve": adminProposalApproveHandler,
657+ "/admin/proposals/:id/reject": adminProposalRejectHandler,
658+ "/admin/proposals/:id/patch": adminProposalPatchHandler,
659+
660+ "/auth/github/start": (req) => startGithubOauth(req),
645661
646662 "/auth/github/callback": async (req) => handleGithubCallback(req),
647663
added src/c21_handlers_admin.ts +115 −0
@@ -0,0 +1,115 @@
1+// c21 — handlers: admin proposal review. Owner-only routes that list
2+// pending edits, show a side-by-side current vs proposed view, and
3+// let the owner mark a proposal approved or rejected. The patch
4+// download is the bridge to git: owner downloads the proposed body,
5+// drops it into content/<section>/<slug>.md on dev, commits, deploys.
6+// No file mutation in the running container.
7+
8+import {
9+ renderNotFound,
10+ htmlResponse,
11+} from "./c51_render_layout.ts";
12+import { getViewer } from "./c32_session.ts";
13+import { ADMIN_USERNAME } from "./c31_site_config.ts";
14+import {
15+ listProposals,
16+ getProposal,
17+ setProposalStatus,
18+} from "./c13_database.ts";
19+import {
20+ renderAdminProposalList,
21+ renderAdminProposalDetail,
22+ renderAdminGate,
23+} from "./c51_render_edit.ts";
24+
25+const requireAdmin = async (req: Request): Promise<{ viewer: string } | Response> => {
26+ const viewer = await getViewer(req);
27+ if (viewer !== ADMIN_USERNAME) {
28+ const html = await renderAdminGate(viewer);
29+ return htmlResponse(html, viewer ? 403 : 401);
30+ }
31+ return { viewer };
32+};
33+
34+// GET /admin/proposals
35+export const adminProposalsHandler = async (req: Request): Promise<Response> => {
36+ const gate = await requireAdmin(req);
37+ if (gate instanceof Response) return gate;
38+ const proposals = listProposals();
39+ const html = await renderAdminProposalList(proposals, gate.viewer);
40+ return htmlResponse(html);
41+};
42+
43+// GET /admin/proposals/:id
44+export const adminProposalDetailHandler = async (
45+ req: Request & { params: { id: string } },
46+): Promise<Response> => {
47+ const gate = await requireAdmin(req);
48+ if (gate instanceof Response) return gate;
49+ const id = parseInt(req.params.id, 10);
50+ if (!Number.isInteger(id) || id <= 0) {
51+ const html = await renderNotFound(`/admin/proposals/${req.params.id}`);
52+ return htmlResponse(html, 404);
53+ }
54+ const proposal = getProposal(id);
55+ if (!proposal) {
56+ const html = await renderNotFound(`/admin/proposals/${id}`);
57+ return htmlResponse(html, 404);
58+ }
59+ const file = Bun.file(`./${proposal.editPath}`);
60+ const currentBody = (await file.exists()) ? await file.text() : "";
61+ const html = await renderAdminProposalDetail(proposal, currentBody, gate.viewer);
62+ return htmlResponse(html);
63+};
64+
65+// POST /admin/proposals/:id/approve
66+export const adminProposalApproveHandler = async (
67+ req: Request & { params: { id: string } },
68+): Promise<Response> => {
69+ const gate = await requireAdmin(req);
70+ if (gate instanceof Response) return gate;
71+ const id = parseInt(req.params.id, 10);
72+ if (!Number.isInteger(id) || id <= 0) return new Response("bad id", { status: 400 });
73+ if (!getProposal(id)) return new Response("not found", { status: 404 });
74+ setProposalStatus(id, "approved", gate.viewer);
75+ return new Response(null, { status: 303, headers: { Location: `/admin/proposals/${id}` } });
76+};
77+
78+// POST /admin/proposals/:id/reject
79+export const adminProposalRejectHandler = async (
80+ req: Request & { params: { id: string } },
81+): Promise<Response> => {
82+ const gate = await requireAdmin(req);
83+ if (gate instanceof Response) return gate;
84+ const id = parseInt(req.params.id, 10);
85+ if (!Number.isInteger(id) || id <= 0) return new Response("bad id", { status: 400 });
86+ if (!getProposal(id)) return new Response("not found", { status: 404 });
87+ const form = await req.formData();
88+ const reason = (form.get("reason") ?? "").toString().slice(0, 500) || null;
89+ setProposalStatus(id, "rejected", gate.viewer, reason);
90+ return new Response(null, { status: 303, headers: { Location: `/admin/proposals/${id}` } });
91+};
92+
93+// GET /admin/proposals/:id/patch
94+//
95+// Returns the proposed body as a downloadable .md file with a header
96+// comment naming the target path. Owner saves it to dev's working
97+// tree, runs `git diff` to review, commits, deploys.
98+export const adminProposalPatchHandler = async (
99+ req: Request & { params: { id: string } },
100+): Promise<Response> => {
101+ const gate = await requireAdmin(req);
102+ if (gate instanceof Response) return gate;
103+ const id = parseInt(req.params.id, 10);
104+ if (!Number.isInteger(id) || id <= 0) return new Response("bad id", { status: 400 });
105+ const proposal = getProposal(id);
106+ if (!proposal) return new Response("not found", { status: 404 });
107+ const filename = proposal.editPath.split("/").pop() ?? `proposal-${id}.md`;
108+ const header = `<!-- proposal #${id} for ${proposal.editPath} by ${proposal.author} on ${new Date(proposal.submittedAt).toISOString()} -->\n`;
109+ return new Response(header + proposal.body, {
110+ headers: {
111+ "Content-Type": "text/markdown; charset=utf-8",
112+ "Content-Disposition": `attachment; filename="${filename}"`,
113+ },
114+ });
115+};
modified src/c21_handlers_auth.ts +32 −11
@@ -20,20 +20,36 @@ const CALLBACK_URL = `${BASE_URL}/auth/github/callback`;
2020
2121 const CLEAR_OAUTH_STATE =
2222 "tdd_oauth_state=; Path=/auth; HttpOnly; Secure; SameSite=Lax; Max-Age=0";
23+const CLEAR_OAUTH_RETURN =
24+ "tdd_oauth_return=; Path=/auth; HttpOnly; Secure; SameSite=Lax; Max-Age=0";
2325
24-export const startGithubOauth = (): Response => {
26+// Same-origin internal path. Anything that doesn't start with a single
27+// "/" or that contains "//" / ":" is rejected to prevent open-redirect.
28+const isSafeReturnTo = (s: string): boolean =>
29+ s.startsWith("/") && !s.startsWith("//") && !s.includes("\n") && !s.includes("\r") && s.length < 1024;
30+
31+export const startGithubOauth = (req?: Request): Response => {
2532 if (!github.isConfigured() || !forgejo.isConfigured()) {
26- // errorPage is async; we wrap below.
2733 return new Response("registration is not configured on this server", { status: 503 });
2834 }
2935 const nonce = randomHex(16);
30- return new Response(null, {
31- status: 302,
32- headers: {
33- Location: github.authorizeUrl(nonce, CALLBACK_URL),
34- "Set-Cookie": `tdd_oauth_state=${nonce}; Path=/auth; HttpOnly; Secure; SameSite=Lax; Max-Age=600`,
35- },
36- });
36+ const headers = new Headers();
37+ headers.append("Set-Cookie", `tdd_oauth_state=${nonce}; Path=/auth; HttpOnly; Secure; SameSite=Lax; Max-Age=600`);
38+
39+ // Optional ?to=<path> query — set a return cookie the callback
40+ // honours after a successful sign-in. Used by /edit and /admin
41+ // links so the user lands back where they came from.
42+ if (req) {
43+ const to = new URL(req.url).searchParams.get("to");
44+ if (to && isSafeReturnTo(to)) {
45+ headers.append(
46+ "Set-Cookie",
47+ `tdd_oauth_return=${encodeURIComponent(to)}; Path=/auth; HttpOnly; Secure; SameSite=Lax; Max-Age=600`,
48+ );
49+ }
50+ }
51+ headers.set("Location", github.authorizeUrl(nonce, CALLBACK_URL));
52+ return new Response(null, { status: 302, headers });
3753 };
3854
3955 const welcomeBody = (reg: forgejo.AgentRegistration): string => {
@@ -102,18 +118,22 @@ export const handleGithubCallback = async (req: Request): Promise<Response> => {
102118
103119 // Login vs register: if the user already exists in Forgejo, this
104120 // is a returning visitor — set the session cookie, redirect to
105- // their dashboard, don't rotate their token.
121+ // their dashboard (or to the cookie-stored returnTo path, when one
122+ // was set by /auth/github/start?to=...), don't rotate their token.
106123 const isExisting = await forgejo.userExists(username);
107124 const sessionToken = await signSession(username);
108125 const sessionCookie = sessionCookieHeader(sessionToken, SESSION_TTL_SEC);
126+ const returnToRaw = cookies.tdd_oauth_return ? decodeURIComponent(cookies.tdd_oauth_return) : null;
127+ const returnTo = returnToRaw && isSafeReturnTo(returnToRaw) ? returnToRaw : null;
109128
110129 if (isExisting) {
111130 return new Response(null, {
112131 status: 302,
113132 headers: new Headers([
114- ["Location", `/agents/${username}`],
133+ ["Location", returnTo ?? `/agents/${username}`],
115134 ["Set-Cookie", sessionCookie],
116135 ["Set-Cookie", CLEAR_OAUTH_STATE],
136+ ["Set-Cookie", CLEAR_OAUTH_RETURN],
117137 ]),
118138 });
119139 }
@@ -140,6 +160,7 @@ export const handleGithubCallback = async (req: Request): Promise<Response> => {
140160 ["Content-Type", "text/html; charset=utf-8"],
141161 ["Set-Cookie", sessionCookie],
142162 ["Set-Cookie", CLEAR_OAUTH_STATE],
163+ ["Set-Cookie", CLEAR_OAUTH_RETURN],
143164 ]),
144165 });
145166 };
added src/c21_handlers_edit.ts +78 −0
@@ -0,0 +1,78 @@
1+// c21 — handlers: the self-hosted editor. Replaces "edit this page on
2+// GitHub" with our own form. Identity still comes from GitHub OAuth
3+// (handled by c21_handlers_auth) but every edit lands as a *proposal*
4+// in our SQLite store; the live page does not change until the owner
5+// approves and applies the patch via git on dev. This is the
6+// guarantee the user asked for: edits never bypass deploy.
7+
8+import { renderNotFound, htmlResponse } from "./c51_render_layout.ts";
9+import { getViewer } from "./c32_session.ts";
10+import { resolveEdit } from "./c32_edit_resolve.ts";
11+import {
12+ parseProposalSubmission,
13+ isNoOpProposal,
14+ ProposalValidationError,
15+} from "./c31_proposals.ts";
16+import { createProposal } from "./c13_database.ts";
17+import {
18+ renderEditFormPage,
19+ renderEditLoginWall,
20+ renderEditThanks,
21+} from "./c51_render_edit.ts";
22+
23+const readCurrentBody = async (filePath: string): Promise<string | null> => {
24+ const file = Bun.file(`./${filePath}`);
25+ if (!(await file.exists())) return null;
26+ return await file.text();
27+};
28+
29+// GET + POST /edit/:section/:slug — single handler, branches on method.
30+export const editPageHandler = async (req: Request & { params: { section: string; slug: string } }): Promise<Response> => {
31+ const resolved = resolveEdit(req.params.section, req.params.slug);
32+ if (!resolved) {
33+ const html = await renderNotFound(`/edit/${req.params.section}/${req.params.slug}`);
34+ return htmlResponse(html, 404);
35+ }
36+
37+ const viewer = await getViewer(req);
38+ if (!viewer) {
39+ const html = await renderEditLoginWall(resolved);
40+ return htmlResponse(html, 401);
41+ }
42+
43+ if (req.method === "POST") {
44+ const form = await req.formData();
45+ const body = (form.get("body") ?? "").toString();
46+ const current = (await readCurrentBody(resolved.filePath)) ?? "";
47+ if (isNoOpProposal(current, body)) {
48+ // No diff — no point queuing it. Send the user back to the form
49+ // without creating a row.
50+ return new Response(null, {
51+ status: 303,
52+ headers: { Location: `/edit/${resolved.section}/${resolved.slug}` },
53+ });
54+ }
55+ let id: number;
56+ try {
57+ const parsed = parseProposalSubmission({
58+ pageUrl: resolved.pageUrl,
59+ editPath: resolved.filePath,
60+ title: resolved.title,
61+ body,
62+ author: viewer,
63+ });
64+ id = createProposal(parsed);
65+ } catch (e) {
66+ if (e instanceof ProposalValidationError) {
67+ return new Response(`proposal rejected: ${e.message}`, { status: 400 });
68+ }
69+ throw e;
70+ }
71+ const html = await renderEditThanks(resolved, id);
72+ return htmlResponse(html);
73+ }
74+
75+ const current = (await readCurrentBody(resolved.filePath)) ?? "";
76+ const html = await renderEditFormPage(resolved, current, viewer);
77+ return htmlResponse(html);
78+};
added src/c31_proposals.test.ts +54 −0
@@ -0,0 +1,54 @@
1+import { test, expect } from "bun:test";
2+import {
3+ parseProposalSubmission,
4+ isNoOpProposal,
5+ ProposalValidationError,
6+ MAX_PROPOSAL_BODY_BYTES,
7+} from "./c31_proposals.ts";
8+
9+const valid = {
10+ pageUrl: "/sama/sorted",
11+ editPath: "content/sama/sorted.md",
12+ title: "S — Sorted",
13+ body: "# Sorted\n\nNew text.",
14+ author: "octocat",
15+};
16+
17+test("accepts a well-formed submission", () => {
18+ const out = parseProposalSubmission(valid);
19+ expect(out.body).toBe(valid.body);
20+ expect(out.author).toBe("octocat");
21+});
22+
23+test("rejects non-object input", () => {
24+ expect(() => parseProposalSubmission(null)).toThrow(ProposalValidationError);
25+ expect(() => parseProposalSubmission("string")).toThrow(ProposalValidationError);
26+});
27+
28+test("rejects missing fields", () => {
29+ expect(() => parseProposalSubmission({ ...valid, body: undefined })).toThrow(ProposalValidationError);
30+ expect(() => parseProposalSubmission({ ...valid, author: 42 })).toThrow(ProposalValidationError);
31+});
32+
33+test("rejects empty body", () => {
34+ expect(() => parseProposalSubmission({ ...valid, body: " \n " })).toThrow(/empty/);
35+});
36+
37+test("rejects empty author", () => {
38+ expect(() => parseProposalSubmission({ ...valid, author: "" })).toThrow(/author/);
39+});
40+
41+test("enforces the body size cap", () => {
42+ const huge = "x".repeat(MAX_PROPOSAL_BODY_BYTES + 1);
43+ expect(() => parseProposalSubmission({ ...valid, body: huge })).toThrow(/exceeds/);
44+});
45+
46+test("accepts a body right at the size cap", () => {
47+ const limit = "x".repeat(MAX_PROPOSAL_BODY_BYTES);
48+ expect(parseProposalSubmission({ ...valid, body: limit }).body.length).toBe(MAX_PROPOSAL_BODY_BYTES);
49+});
50+
51+test("isNoOpProposal flags identical bodies", () => {
52+ expect(isNoOpProposal("a", "a")).toBe(true);
53+ expect(isNoOpProposal("a", "a ")).toBe(false);
54+});
added src/c31_proposals.ts +63 −0
@@ -0,0 +1,63 @@
1+// c31 — model: parser for proposal submissions. The DB row shape lives
2+// in c13_database.ts (ProposalRow / NewProposal); this file validates
3+// untyped form input before it reaches the DB.
4+//
5+// A proposal is a markdown-body edit suggestion for a doc page. We
6+// cap the body size and reject empty submissions so the proposals
7+// table stays bounded and admins aren't reviewing accidental no-ops.
8+
9+import type { NewProposal } from "./c13_database.ts";
10+
11+export const MAX_PROPOSAL_BODY_BYTES = 256 * 1024; // 256 KB
12+
13+export interface ProposalSubmissionInput {
14+ pageUrl: string;
15+ editPath: string;
16+ title: string;
17+ body: string;
18+ author: string;
19+}
20+
21+export class ProposalValidationError extends Error {
22+ constructor(message: string) {
23+ super(message);
24+ this.name = "ProposalValidationError";
25+ }
26+}
27+
28+const requireString = (v: unknown, label: string): string => {
29+ if (typeof v !== "string") throw new ProposalValidationError(`${label} must be a string`);
30+ return v;
31+};
32+
33+export const parseProposalSubmission = (input: unknown): NewProposal => {
34+ if (input === null || typeof input !== "object") {
35+ throw new ProposalValidationError("submission must be an object");
36+ }
37+ const obj = input as Record<string, unknown>;
38+ const body = requireString(obj.body, "body");
39+ if (body.trim().length === 0) {
40+ throw new ProposalValidationError("body cannot be empty");
41+ }
42+ const bodyBytes = new TextEncoder().encode(body).length;
43+ if (bodyBytes > MAX_PROPOSAL_BODY_BYTES) {
44+ throw new ProposalValidationError(`body exceeds the ${MAX_PROPOSAL_BODY_BYTES / 1024} KB limit (got ${Math.round(bodyBytes / 1024)} KB)`);
45+ }
46+ const author = requireString(obj.author, "author");
47+ if (author.trim().length === 0) {
48+ throw new ProposalValidationError("author cannot be empty");
49+ }
50+ return {
51+ pageUrl: requireString(obj.pageUrl, "pageUrl"),
52+ editPath: requireString(obj.editPath, "editPath"),
53+ title: requireString(obj.title, "title"),
54+ body,
55+ author,
56+ };
57+};
58+
59+// Returns true when the proposed body is byte-identical to the
60+// current page body. Used by the edit handler to skip storing
61+// no-op submissions.
62+export const isNoOpProposal = (currentBody: string, proposedBody: string): boolean =>
63+ currentBody === proposedBody;
modified src/c31_site_config.ts +9 −0
@@ -8,3 +8,12 @@ export const LIVE_REPO_NAME = "tdd.md";
88 // Number of recent commits the live-reports view samples from the
99 // in-container git-history bundle.
1010 export const LIVE_FETCH_COUNT = 100;
11+
12+// Owner / admin GitHub login. Gates /admin/* routes (proposal review).
13+// Override per-environment via TDD_ADMIN_USER if needed.
14+export const ADMIN_USERNAME = process.env.TDD_ADMIN_USER ?? "syntaxai";
15+
16+// Self-hosted Forgejo mirror — what we link to when the docs layout
17+// says "view source on our git". The link goes to the canonical
18+// rendered file in the mirror, NOT to a web editor (we have our own).
19+export const SELF_HOSTED_REPO_BLOB_BASE = "https://git.tdd.md/syntaxai/tdd.md/src/branch/main";
added src/c32_edit_resolve.test.ts +44 −0
@@ -0,0 +1,44 @@
1+import { test, expect } from "bun:test";
2+import { resolveEdit } from "./c32_edit_resolve.ts";
3+
4+test("resolves an existing sama page", () => {
5+ const r = resolveEdit("sama", "sorted");
6+ expect(r).not.toBeNull();
7+ expect(r?.pageUrl).toBe("/sama/sorted");
8+ expect(r?.filePath).toBe("content/sama/sorted.md");
9+ expect(r?.title).toMatch(/Sorted/);
10+});
11+
12+test("resolves an existing guide", () => {
13+ const r = resolveEdit("guides", "claude-code");
14+ expect(r).not.toBeNull();
15+ expect(r?.filePath).toBe("content/guides/claude-code.md");
16+});
17+
18+test("resolves an existing blog post", () => {
19+ const r = resolveEdit("blog", "from-rules-to-checks");
20+ expect(r).not.toBeNull();
21+ expect(r?.filePath).toBe("content/blog/from-rules-to-checks.md");
22+});
23+
24+test("returns null for unknown section", () => {
25+ expect(resolveEdit("admin", "x")).toBeNull();
26+ expect(resolveEdit("etc", "passwd")).toBeNull();
27+});
28+
29+test("returns null for unknown slug in a known section", () => {
30+ expect(resolveEdit("sama", "nonexistent-discipline")).toBeNull();
31+});
32+
33+test("rejects path traversal in slug", () => {
34+ expect(resolveEdit("sama", "../sorted")).toBeNull();
35+ expect(resolveEdit("sama", "..")).toBeNull();
36+ expect(resolveEdit("sama", "/etc/passwd")).toBeNull();
37+});
38+
39+test("rejects unsafe slug shapes", () => {
40+ expect(resolveEdit("sama", "Sorted")).toBeNull(); // uppercase
41+ expect(resolveEdit("sama", "")).toBeNull();
42+ expect(resolveEdit("sama", "with space")).toBeNull();
43+ expect(resolveEdit("sama", "-leading-dash")).toBeNull();
44+});
added src/c32_edit_resolve.ts +58 −0
@@ -0,0 +1,58 @@
1+// c32 — pure logic: given a (section, slug) tuple from a /edit/<...>
2+// URL, resolve it to the editable resource (page URL, file path on
3+// disk, page title) — or return null if the combination doesn't map
4+// to an existing doc page. Pure: no I/O. The handler does the actual
5+// fs read for the current body.
6+//
7+// Editable sections are the ones that already have a registry: sama,
8+// guides, blog. Slug must match an entry in the corresponding registry
9+// — this prevents arbitrary file writes via /edit/../../etc/passwd
10+// style inputs.
11+
12+import { ALL_SAMA } from "./c31_sama.ts";
13+import { ALL_GUIDES } from "./c31_guides.ts";
14+import { ALL_POSTS } from "./c31_blog.ts";
15+
16+export type EditableSection = "sama" | "guides" | "blog";
17+
18+export interface ResolvedEdit {
19+ section: EditableSection;
20+ slug: string;
21+ pageUrl: string;
22+ filePath: string;
23+ title: string;
24+}
25+
26+const SECTIONS = new Set<EditableSection>(["sama", "guides", "blog"]);
27+
28+const isValidSection = (s: string): s is EditableSection => SECTIONS.has(s as EditableSection);
29+
30+const SAFE_SLUG = /^[a-z0-9][a-z0-9-]*$/;
31+
32+const lookupTitle = (section: EditableSection, slug: string): string | null => {
33+ if (section === "sama") {
34+ const e = ALL_SAMA.find((d) => d.slug === slug);
35+ return e ? `${e.letter} — ${e.title}` : null;
36+ }
37+ if (section === "guides") {
38+ const e = ALL_GUIDES.find((g) => g.slug === slug);
39+ return e?.title ?? null;
40+ }
41+ // blog
42+ const e = ALL_POSTS.find((p) => p.slug === slug);
43+ return e?.title ?? null;
44+};
45+
46+export const resolveEdit = (section: string, slug: string): ResolvedEdit | null => {
47+ if (!isValidSection(section)) return null;
48+ if (!SAFE_SLUG.test(slug)) return null;
49+ const title = lookupTitle(section, slug);
50+ if (title === null) return null;
51+ return {
52+ section,
53+ slug,
54+ pageUrl: `/${section}/${slug}`,
55+ filePath: `content/${section}/${slug}.md`,
56+ title,
57+ };
58+};
modified src/c51_render_docs_layout.ts +16 −4
@@ -19,8 +19,7 @@ import {
1919 escape,
2020 type PageOptions,
2121 } from "./c51_render_layout.ts";
22-
23-const REPO_EDIT_BASE = "https://github.com/syntaxai/tdd.md/edit/main";
22+import { SELF_HOSTED_REPO_BLOB_BASE } from "./c31_site_config.ts";
2423
2524 export interface DocsPageOptions extends Omit<PageOptions, "bodyHtml"> {
2625 // The route path the user is on, e.g. "/sama/sorted". Used to
@@ -67,10 +66,23 @@ const renderAnchorRail = (anchors: Anchor[]): string => {
6766 </aside>`;
6867 };
6968
69+// Derive (section, slug) from a content/<section>/<slug>.md editPath.
70+// Returns null when the path doesn't follow the convention (in which
71+// case there's no editor route to link to).
72+const sectionSlugFromEditPath = (editPath: string): { section: string; slug: string } | null => {
73+ const m = /^content\/([a-z]+)\/([a-z0-9][a-z0-9-]*)\.md$/.exec(editPath);
74+ return m ? { section: m[1]!, slug: m[2]! } : null;
75+};
76+
7077 const renderEditLink = (editPath: string | null): string => {
7178 if (!editPath) return "";
72- const url = `${REPO_EDIT_BASE}/${editPath}`;
73- return `<p class="docs-edit"><a href="${escape(url)}" rel="noopener" target="_blank">edit this page on GitHub →</a></p>`;
79+ const sourceUrl = `${SELF_HOSTED_REPO_BLOB_BASE}/${editPath}`;
80+ const ss = sectionSlugFromEditPath(editPath);
81+ const editHref = ss ? `/edit/${ss.section}/${ss.slug}` : null;
82+ const editAnchor = editHref
83+ ? `<a href="${escape(editHref)}">propose an edit →</a> · `
84+ : "";
85+ return `<p class="docs-edit">${editAnchor}<a href="${escape(sourceUrl)}" rel="noopener" target="_blank">view source on git.tdd.md →</a></p>`;
7486 };
7587
7688 const renderPrevNext = (loc: ResolvedDocsLocation | null): string => {
added src/c51_render_edit.ts +196 −0
@@ -0,0 +1,196 @@
1+// c51 (edit) — UI: edit-form, login-required prompt, admin proposal
2+// list, and side-by-side current vs proposed view. Composes the
3+// docs layout's chrome via renderPage with bodyHtml so the form
4+// can use real <form> elements (markdown would escape them).
5+
6+import {
7+ renderPage,
8+ escape,
9+} from "./c51_render_layout.ts";
10+import type { ProposalRow } from "./c13_database.ts";
11+import type { ResolvedEdit } from "./c32_edit_resolve.ts";
12+
13+const ts = (n: number): string => new Date(n).toISOString().replace("T", " ").slice(0, 19) + " UTC";
14+
15+const layoutWrap = (innerHtml: string): string =>
16+ `<main class="md edit-page"><div class="edit-container">${innerHtml}</div></main>`;
17+
18+// Override the standard <main class="md">: the edit experience needs
19+// full-width form controls, not the doc-layout's three columns.
20+const editBodyClass = "edit-body";
21+
22+// -------- /edit/:section/:slug — form for a logged-in user --------
23+
24+export const renderEditFormPage = async (
25+ resolved: ResolvedEdit,
26+ currentBody: string,
27+ viewer: string,
28+): Promise<string> => {
29+ const inner = `<h1>edit · ${escape(resolved.title)}</h1>
30+<p class="edit-meta">
31+ Editing <code>${escape(resolved.filePath)}</code> as <strong>${escape(viewer)}</strong>.
32+ Submitting saves a <em>proposal</em> — the live page does not change.
33+ <a href="${escape(resolved.pageUrl)}">view the live page</a> ·
34+ <a href="/auth/logout">log out</a>
35+</p>
36+<form method="post" action="/edit/${escape(resolved.section)}/${escape(resolved.slug)}" class="edit-form">
37+ <textarea name="body" class="edit-textarea" rows="32" spellcheck="false">${escape(currentBody)}</textarea>
38+ <div class="edit-actions">
39+ <button type="submit">submit proposal</button>
40+ <a class="edit-cancel" href="${escape(resolved.pageUrl)}">cancel</a>
41+ </div>
42+</form>
43+<p class="edit-note">
44+ Proposals are queued for review at <code>/admin/proposals</code>. The owner downloads
45+ the proposed body, applies it via git, and the next deploy publishes the change. No edits
46+ bypass git or the deploy pipeline.
47+</p>`;
48+ return renderPage({
49+ title: `edit · ${resolved.title} — tdd.md`,
50+ bodyHtml: layoutWrap(inner),
51+ description: `Suggest an edit to ${resolved.title} on tdd.md. Proposals are queued for owner review and never bypass git.`,
52+ ogPath: `https://tdd.md/edit/${resolved.section}/${resolved.slug}`,
53+ noindex: true,
54+ bodyClass: editBodyClass,
55+ });
56+};
57+
58+// -------- login wall before the form --------
59+
60+export const renderEditLoginWall = async (
61+ resolved: ResolvedEdit,
62+): Promise<string> => {
63+ const returnTo = `/edit/${resolved.section}/${resolved.slug}`;
64+ const inner = `<h1>edit · ${escape(resolved.title)}</h1>
65+<p>To suggest an edit you need to sign in via GitHub. We use GitHub only for identity — no edits or commits go through GitHub from here.</p>
66+<p><a class="edit-login-button" href="/auth/github/start?to=${encodeURIComponent(returnTo)}">sign in with GitHub →</a></p>
67+<p class="edit-meta">After sign-in you'll land back on this edit form. Your proposal stays in our SQLite store until the owner approves and applies it via git.</p>
68+<p><a href="${escape(resolved.pageUrl)}">← back to the page</a></p>`;
69+ return renderPage({
70+ title: `sign in to edit · ${resolved.title} — tdd.md`,
71+ bodyHtml: layoutWrap(inner),
72+ description: `Sign in via GitHub to propose an edit to ${resolved.title} on tdd.md.`,
73+ noindex: true,
74+ bodyClass: editBodyClass,
75+ });
76+};
77+
78+// -------- thank-you page after submit --------
79+
80+export const renderEditThanks = async (
81+ resolved: ResolvedEdit,
82+ proposalId: number,
83+): Promise<string> => {
84+ const inner = `<h1>thanks — proposal #${proposalId} queued</h1>
85+<p>Your edit to <a href="${escape(resolved.pageUrl)}"><code>${escape(resolved.pageUrl)}</code></a> is in the review queue.</p>
86+<p class="edit-meta">The owner will review pending proposals at <code>/admin/proposals</code>. The live page won't change until they approve and apply the patch via git.</p>
87+<p><a href="${escape(resolved.pageUrl)}">← back to the page</a> · <a href="/edit/${escape(resolved.section)}/${escape(resolved.slug)}">propose another edit</a></p>`;
88+ return renderPage({
89+ title: `proposal #${proposalId} queued — tdd.md`,
90+ bodyHtml: layoutWrap(inner),
91+ noindex: true,
92+ bodyClass: editBodyClass,
93+ });
94+};
95+
96+// -------- admin: proposal list --------
97+
98+const statusBadge = (s: ProposalRow["status"]): string => {
99+ const cls = s === "approved" ? "edit-status edit-status-approved"
100+ : s === "rejected" ? "edit-status edit-status-rejected"
101+ : "edit-status edit-status-pending";
102+ return `<span class="${cls}">${s}</span>`;
103+};
104+
105+export const renderAdminProposalList = async (
106+ proposals: ProposalRow[],
107+ viewer: string,
108+): Promise<string> => {
109+ const rows = proposals.length === 0
110+ ? `<tr><td colspan="6" class="edit-empty">no proposals yet</td></tr>`
111+ : proposals.map((p) => `<tr>
112+ <td>#${p.id}</td>
113+ <td><a href="${escape(p.pageUrl)}"><code>${escape(p.pageUrl)}</code></a></td>
114+ <td>${escape(p.author)}</td>
115+ <td>${escape(ts(p.submittedAt))}</td>
116+ <td>${statusBadge(p.status)}</td>
117+ <td><a href="/admin/proposals/${p.id}">review →</a></td>
118+</tr>`).join("\n");
119+ const inner = `<h1>proposal queue</h1>
120+<p class="edit-meta">${proposals.length} proposal${proposals.length === 1 ? "" : "s"} · signed in as <strong>${escape(viewer)}</strong>.</p>
121+<table class="edit-table">
122+ <thead><tr><th>id</th><th>page</th><th>author</th><th>submitted</th><th>status</th><th></th></tr></thead>
123+ <tbody>${rows}</tbody>
124+</table>`;
125+ return renderPage({
126+ title: "proposals — tdd.md",
127+ bodyHtml: layoutWrap(inner),
128+ noindex: true,
129+ bodyClass: editBodyClass,
130+ });
131+};
132+
133+// -------- admin: single proposal review (current vs proposed) --------
134+
135+export const renderAdminProposalDetail = async (
136+ proposal: ProposalRow,
137+ currentBody: string,
138+ viewer: string,
139+): Promise<string> => {
140+ const reviewedLine = proposal.status === "pending"
141+ ? ""
142+ : `<p class="edit-meta">${escape(proposal.status)} by ${escape(proposal.reviewedBy ?? "?")} at ${escape(ts(proposal.reviewedAt ?? 0))}${proposal.rejectReason ? ` · reason: ${escape(proposal.rejectReason)}` : ""}</p>`;
143+ const actions = proposal.status === "pending"
144+ ? `<form method="post" action="/admin/proposals/${proposal.id}/approve" class="edit-form-inline">
145+ <button type="submit">approve</button>
146+</form>
147+<form method="post" action="/admin/proposals/${proposal.id}/reject" class="edit-form-inline">
148+ <input type="text" name="reason" placeholder="reject reason (optional)" />
149+ <button type="submit">reject</button>
150+</form>
151+<a class="edit-action-link" href="/admin/proposals/${proposal.id}/patch">download patch (.md)</a>`
152+ : `<a class="edit-action-link" href="/admin/proposals/${proposal.id}/patch">download patch (.md)</a>`;
153+ const inner = `<h1>proposal #${proposal.id} · ${escape(proposal.title)}</h1>
154+<p class="edit-meta">
155+ Page: <a href="${escape(proposal.pageUrl)}"><code>${escape(proposal.pageUrl)}</code></a> ·
156+ File: <code>${escape(proposal.editPath)}</code> ·
157+ Author: <strong>${escape(proposal.author)}</strong> ·
158+ Submitted: ${escape(ts(proposal.submittedAt))} ·
159+ Status: ${statusBadge(proposal.status)}
160+</p>
161+${reviewedLine}
162+<p class="edit-meta">Reviewing as <strong>${escape(viewer)}</strong>. Approving sets the status; the live page does not change until you commit the patch on dev and run a deploy.</p>
163+<div class="edit-actions edit-actions-row">${actions}</div>
164+<h2>diff (current ⇢ proposed)</h2>
165+<div class="edit-diff">
166+ <div class="edit-diff-pane">
167+ <p class="edit-diff-label">current</p>
168+ <pre><code>${escape(currentBody)}</code></pre>
169+ </div>
170+ <div class="edit-diff-pane">
171+ <p class="edit-diff-label">proposed</p>
172+ <pre><code>${escape(proposal.body)}</code></pre>
173+ </div>
174+</div>
175+<p><a href="/admin/proposals">← back to queue</a></p>`;
176+ return renderPage({
177+ title: `proposal #${proposal.id} — tdd.md`,
178+ bodyHtml: layoutWrap(inner),
179+ noindex: true,
180+ bodyClass: editBodyClass,
181+ });
182+};
183+
184+// -------- admin gate page (not the owner) --------
185+
186+export const renderAdminGate = async (viewer: string | null): Promise<string> => {
187+ const inner = viewer
188+ ? `<h1>admin · access denied</h1><p>Signed in as <strong>${escape(viewer)}</strong>, but this area is owner-only. <a href="/">← back home</a></p>`
189+ : `<h1>admin · sign in</h1><p><a class="edit-login-button" href="/auth/github/start?to=${encodeURIComponent("/admin/proposals")}">sign in with GitHub →</a></p>`;
190+ return renderPage({
191+ title: "admin — tdd.md",
192+ bodyHtml: layoutWrap(inner),
193+ noindex: true,
194+ bodyClass: editBodyClass,
195+ });
196+};