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]>
13 files changed · +966 −16
public/style.css
+162
−0
| @@ -699,3 +699,165 @@ main.md table.test-stability td.test-stab-num { | ||
| 699 | 699 | margin-bottom: 1.5rem; |
| 700 | 700 | } |
| 701 | 701 | } |
| 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 | +} | |
src/c13_database.ts
+122
−0
| @@ -35,6 +35,22 @@ const getDb = (): Database => { | ||
| 35 | 35 | ); |
| 36 | 36 | CREATE INDEX IF NOT EXISTS idx_projects_registered_by |
| 37 | 37 | 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); | |
| 38 | 54 | `); |
| 39 | 55 | return db; |
| 40 | 56 | }; |
| @@ -194,6 +210,112 @@ export const listActiveProjects = (): ProjectRow[] => { | ||
| 194 | 210 | return rows.map(rowToProject); |
| 195 | 211 | }; |
| 196 | 212 | |
| 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 | + | |
| 197 | 319 | // Latest verdict per (owner, repo) across all agents — drives the |
| 198 | 320 | // leaderboard and the /agents index. |
| 199 | 321 | export const allLatestRuns = (): { owner: string; repo: string; verdict: Verdict }[] => { |
src/c21_app.ts
+17
−1
| @@ -57,6 +57,14 @@ import { | ||
| 57 | 57 | samaLandingHandler, |
| 58 | 58 | samaSlugHandler, |
| 59 | 59 | } 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"; | |
| 60 | 68 | |
| 61 | 69 | const HOME_MD = "./content/home.md"; |
| 62 | 70 | const GAME_DIR = "./content/games"; |
| @@ -641,7 +649,15 @@ ${rows} | ||
| 641 | 649 | }); |
| 642 | 650 | }, |
| 643 | 651 | |
| 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), | |
| 645 | 661 | |
| 646 | 662 | "/auth/github/callback": async (req) => handleGithubCallback(req), |
| 647 | 663 | |
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 | +}; | |
src/c21_handlers_auth.ts
+32
−11
| @@ -20,20 +20,36 @@ const CALLBACK_URL = `${BASE_URL}/auth/github/callback`; | ||
| 20 | 20 | |
| 21 | 21 | const CLEAR_OAUTH_STATE = |
| 22 | 22 | "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"; | |
| 23 | 25 | |
| 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 => { | |
| 25 | 32 | if (!github.isConfigured() || !forgejo.isConfigured()) { |
| 26 | - // errorPage is async; we wrap below. | |
| 27 | 33 | return new Response("registration is not configured on this server", { status: 503 }); |
| 28 | 34 | } |
| 29 | 35 | 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 }); | |
| 37 | 53 | }; |
| 38 | 54 | |
| 39 | 55 | const welcomeBody = (reg: forgejo.AgentRegistration): string => { |
| @@ -102,18 +118,22 @@ export const handleGithubCallback = async (req: Request): Promise<Response> => { | ||
| 102 | 118 | |
| 103 | 119 | // Login vs register: if the user already exists in Forgejo, this |
| 104 | 120 | // 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. | |
| 106 | 123 | const isExisting = await forgejo.userExists(username); |
| 107 | 124 | const sessionToken = await signSession(username); |
| 108 | 125 | 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; | |
| 109 | 128 | |
| 110 | 129 | if (isExisting) { |
| 111 | 130 | return new Response(null, { |
| 112 | 131 | status: 302, |
| 113 | 132 | headers: new Headers([ |
| 114 | - ["Location", `/agents/${username}`], | |
| 133 | + ["Location", returnTo ?? `/agents/${username}`], | |
| 115 | 134 | ["Set-Cookie", sessionCookie], |
| 116 | 135 | ["Set-Cookie", CLEAR_OAUTH_STATE], |
| 136 | + ["Set-Cookie", CLEAR_OAUTH_RETURN], | |
| 117 | 137 | ]), |
| 118 | 138 | }); |
| 119 | 139 | } |
| @@ -140,6 +160,7 @@ export const handleGithubCallback = async (req: Request): Promise<Response> => { | ||
| 140 | 160 | ["Content-Type", "text/html; charset=utf-8"], |
| 141 | 161 | ["Set-Cookie", sessionCookie], |
| 142 | 162 | ["Set-Cookie", CLEAR_OAUTH_STATE], |
| 163 | + ["Set-Cookie", CLEAR_OAUTH_RETURN], | |
| 143 | 164 | ]), |
| 144 | 165 | }); |
| 145 | 166 | }; |
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 | +}; | |
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 | +}); | |
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; | |
src/c31_site_config.ts
+9
−0
| @@ -8,3 +8,12 @@ export const LIVE_REPO_NAME = "tdd.md"; | ||
| 8 | 8 | // Number of recent commits the live-reports view samples from the |
| 9 | 9 | // in-container git-history bundle. |
| 10 | 10 | 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"; | |
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 | +}); | |
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 | +}; | |
src/c51_render_docs_layout.ts
+16
−4
| @@ -19,8 +19,7 @@ import { | ||
| 19 | 19 | escape, |
| 20 | 20 | type PageOptions, |
| 21 | 21 | } 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"; | |
| 24 | 23 | |
| 25 | 24 | export interface DocsPageOptions extends Omit<PageOptions, "bodyHtml"> { |
| 26 | 25 | // The route path the user is on, e.g. "/sama/sorted". Used to |
| @@ -67,10 +66,23 @@ const renderAnchorRail = (anchors: Anchor[]): string => { | ||
| 67 | 66 | </aside>`; |
| 68 | 67 | }; |
| 69 | 68 | |
| 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 | + | |
| 70 | 77 | const renderEditLink = (editPath: string | null): string => { |
| 71 | 78 | 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>`; | |
| 74 | 86 | }; |
| 75 | 87 | |
| 76 | 88 | const renderPrevNext = (loc: ResolvedDocsLocation | null): string => { |
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 | +}; | |