a7a8d04441d1a356b0ac9ce91c9e314fe595bfd3 diff --git a/public/style.css b/public/style.css index 8cc17406a51e8beafb6d7599474f9ba7507388da..a80fedbbba955a56afa4ea23e468910a80d1b9e3 100644 --- a/public/style.css +++ b/public/style.css @@ -699,3 +699,165 @@ main.md table.test-stability td.test-stab-num { margin-bottom: 1.5rem; } } + +/* ----------------------------------------------------------------- + Editor pages — /edit/:section/:slug, /admin/proposals/*. Wider + content area, full-width form controls, side-by-side diff blocks. +----------------------------------------------------------------- */ + +.edit-body main.md { + max-width: none; + padding: 0; +} + +.edit-container { + max-width: 1100px; + margin: 0 auto; + padding: 1rem 1.5rem 4rem; +} + +.edit-meta { + color: var(--muted); + font-size: 0.9rem; +} + +.edit-form { display: flex; flex-direction: column; gap: 0.8rem; } +.edit-textarea { + width: 100%; + font-family: ui-monospace, "SF Mono", "JetBrains Mono", "Fira Code", Menlo, Consolas, monospace; + font-size: 0.92rem; + line-height: 1.55; + padding: 0.8rem 1rem; + background: color-mix(in srgb, var(--muted) 6%, transparent); + color: var(--fg); + border: 1px solid color-mix(in srgb, var(--muted) 30%, transparent); + border-radius: 4px; + resize: vertical; + outline: none; +} +.edit-textarea:focus { border-color: var(--accent); } + +.edit-actions { display: flex; gap: 0.8rem; align-items: center; } +.edit-actions-row { flex-wrap: wrap; } +.edit-actions button, +.edit-form-inline button { + font: inherit; + font-family: ui-monospace, "SF Mono", "JetBrains Mono", "Fira Code", Menlo, Consolas, monospace; + font-size: 0.88rem; + padding: 0.55rem 1rem; + background: var(--accent); + color: var(--bg); + border: 1px solid var(--accent); + border-radius: 4px; + cursor: pointer; +} +.edit-actions button:hover, +.edit-form-inline button:hover { filter: brightness(1.1); } +.edit-cancel, +.edit-action-link { + color: var(--muted); + text-decoration: none; + font-size: 0.9rem; +} +.edit-cancel:hover, .edit-action-link:hover { color: var(--accent); } + +.edit-form-inline { + display: inline-flex; + gap: 0.4rem; + align-items: center; +} +.edit-form-inline input[type="text"] { + font-family: inherit; + padding: 0.45rem 0.7rem; + background: color-mix(in srgb, var(--muted) 6%, transparent); + color: var(--fg); + border: 1px solid color-mix(in srgb, var(--muted) 30%, transparent); + border-radius: 4px; + font-size: 0.85rem; + min-width: 16rem; +} + +.edit-login-button { + display: inline-block; + padding: 0.7rem 1.2rem; + background: var(--accent); + color: var(--bg); + text-decoration: none; + border-radius: 4px; + font-weight: 500; +} +.edit-login-button:hover { filter: brightness(1.1); } + +.edit-note { + margin-top: 1.2rem; + color: var(--muted); + font-size: 0.88rem; + border-left: 2px solid color-mix(in srgb, var(--muted) 30%, transparent); + padding-left: 0.8rem; +} + +.edit-table { + width: 100%; + border-collapse: collapse; + margin: 1rem 0 1.5rem; +} +.edit-table th, .edit-table td { + text-align: left; + padding: 0.55rem 0.8rem; + border-bottom: 1px solid color-mix(in srgb, var(--muted) 18%, transparent); + font-size: 0.9rem; +} +.edit-table th { + font-weight: 600; + color: var(--muted); + text-transform: uppercase; + font-size: 0.72rem; + letter-spacing: 0.05em; +} +.edit-empty { color: var(--muted); text-align: center; padding: 2rem !important; } + +.edit-status { + font-size: 0.78rem; + padding: 0.15rem 0.55rem; + border-radius: 999px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.04em; +} +.edit-status-pending { background: color-mix(in srgb, var(--accent) 18%, transparent); color: var(--accent); } +.edit-status-approved { background: color-mix(in srgb, var(--green) 18%, transparent); color: var(--green); } +.edit-status-rejected { background: color-mix(in srgb, var(--red) 18%, transparent); color: var(--red); } + +.edit-diff { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + margin: 1rem 0 2rem; +} +.edit-diff-pane { + border: 1px solid color-mix(in srgb, var(--muted) 25%, transparent); + border-radius: 4px; + background: color-mix(in srgb, var(--muted) 5%, transparent); +} +.edit-diff-label { + margin: 0; + padding: 0.5rem 0.8rem; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted); + border-bottom: 1px solid color-mix(in srgb, var(--muted) 25%, transparent); +} +.edit-diff-pane pre { + margin: 0; + padding: 0.8rem; + max-height: 60vh; + overflow: auto; + font-size: 0.82rem; + line-height: 1.5; + white-space: pre-wrap; +} + +@media (max-width: 900px) { + .edit-diff { grid-template-columns: 1fr; } +} diff --git a/src/c13_database.ts b/src/c13_database.ts index c565cfbaaab796146705329a0deda509e4c99186..1bcc64b78d775f21c4d6f55db79a16eba2c73470 100644 --- a/src/c13_database.ts +++ b/src/c13_database.ts @@ -35,6 +35,22 @@ const getDb = (): Database => { ); CREATE INDEX IF NOT EXISTS idx_projects_registered_by ON projects(registered_by); + + CREATE TABLE IF NOT EXISTS proposals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + page_url TEXT NOT NULL, + edit_path TEXT NOT NULL, + title TEXT NOT NULL, + body TEXT NOT NULL, + author TEXT NOT NULL, + submitted_at INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + reviewed_at INTEGER, + reviewed_by TEXT, + reject_reason TEXT + ); + CREATE INDEX IF NOT EXISTS idx_proposals_status + ON proposals(status, submitted_at DESC); `); return db; }; @@ -194,6 +210,112 @@ export const listActiveProjects = (): ProjectRow[] => { return rows.map(rowToProject); }; +// --------------------------------------------------------------------- +// Proposals — page-edit suggestions submitted via the self-hosted +// editor. Stored pending until the admin approves or rejects them. +// Approved proposals never auto-mutate the live page; they're +// downloaded as a patch and committed by the owner manually, which is +// the "edit doesn't immediately replace live" guarantee. +// --------------------------------------------------------------------- + +export type ProposalStatus = "pending" | "approved" | "rejected"; + +export interface ProposalRow { + id: number; + pageUrl: string; + editPath: string; + title: string; + body: string; + author: string; + submittedAt: number; + status: ProposalStatus; + reviewedAt: number | null; + reviewedBy: string | null; + rejectReason: string | null; +} + +interface ProposalDbRow { + id: number; + page_url: string; + edit_path: string; + title: string; + body: string; + author: string; + submitted_at: number; + status: string; + reviewed_at: number | null; + reviewed_by: string | null; + reject_reason: string | null; +} + +const rowToProposal = (r: ProposalDbRow): ProposalRow => { + const status: ProposalStatus = r.status === "approved" || r.status === "rejected" ? r.status : "pending"; + return { + id: r.id, + pageUrl: r.page_url, + editPath: r.edit_path, + title: r.title, + body: r.body, + author: r.author, + submittedAt: r.submitted_at, + status, + reviewedAt: r.reviewed_at, + reviewedBy: r.reviewed_by, + rejectReason: r.reject_reason, + }; +}; + +export interface NewProposal { + pageUrl: string; + editPath: string; + title: string; + body: string; + author: string; +} + +export const createProposal = (p: NewProposal): number => { + const result = getDb().run( + `INSERT INTO proposals (page_url, edit_path, title, body, author, submitted_at) + VALUES (?, ?, ?, ?, ?, ?)`, + [p.pageUrl, p.editPath, p.title, p.body, p.author, Date.now()], + ); + return Number(result.lastInsertRowid); +}; + +export const getProposal = (id: number): ProposalRow | null => { + const row = getDb() + .query(`SELECT * FROM proposals WHERE id = ?`) + .get(id); + return row ? rowToProposal(row) : null; +}; + +export const listProposals = (status?: ProposalStatus): ProposalRow[] => { + if (status) { + return getDb() + .query( + `SELECT * FROM proposals WHERE status = ? ORDER BY submitted_at DESC`, + ) + .all(status) + .map(rowToProposal); + } + return getDb() + .query(`SELECT * FROM proposals ORDER BY submitted_at DESC LIMIT 200`) + .all() + .map(rowToProposal); +}; + +export const setProposalStatus = ( + id: number, + status: ProposalStatus, + reviewer: string, + rejectReason: string | null = null, +): void => { + getDb().run( + `UPDATE proposals SET status = ?, reviewed_at = ?, reviewed_by = ?, reject_reason = ? WHERE id = ?`, + [status, Date.now(), reviewer, rejectReason, id], + ); +}; + // Latest verdict per (owner, repo) across all agents — drives the // leaderboard and the /agents index. export const allLatestRuns = (): { owner: string; repo: string; verdict: Verdict }[] => { diff --git a/src/c21_app.ts b/src/c21_app.ts index 1edd511e9abd582c3fa549c2fc0ef7509939d0a9..dcea1446501a633804447655cf7157d3c33e074b 100644 --- a/src/c21_app.ts +++ b/src/c21_app.ts @@ -57,6 +57,14 @@ import { samaLandingHandler, samaSlugHandler, } from "./c21_handlers_sama.ts"; +import { editPageHandler } from "./c21_handlers_edit.ts"; +import { + adminProposalsHandler, + adminProposalDetailHandler, + adminProposalApproveHandler, + adminProposalRejectHandler, + adminProposalPatchHandler, +} from "./c21_handlers_admin.ts"; const HOME_MD = "./content/home.md"; const GAME_DIR = "./content/games"; @@ -641,7 +649,15 @@ ${rows} }); }, - "/auth/github/start": (_req) => startGithubOauth(), + "/edit/:section/:slug": editPageHandler, + + "/admin/proposals": adminProposalsHandler, + "/admin/proposals/:id": adminProposalDetailHandler, + "/admin/proposals/:id/approve": adminProposalApproveHandler, + "/admin/proposals/:id/reject": adminProposalRejectHandler, + "/admin/proposals/:id/patch": adminProposalPatchHandler, + + "/auth/github/start": (req) => startGithubOauth(req), "/auth/github/callback": async (req) => handleGithubCallback(req), diff --git a/src/c21_handlers_admin.ts b/src/c21_handlers_admin.ts new file mode 100644 index 0000000000000000000000000000000000000000..24919ab54b927109afc5dbf95f4a95c4202b4cac --- /dev/null +++ b/src/c21_handlers_admin.ts @@ -0,0 +1,115 @@ +// c21 — handlers: admin proposal review. Owner-only routes that list +// pending edits, show a side-by-side current vs proposed view, and +// let the owner mark a proposal approved or rejected. The patch +// download is the bridge to git: owner downloads the proposed body, +// drops it into content/
/.md on dev, commits, deploys. +// No file mutation in the running container. + +import { + renderNotFound, + htmlResponse, +} from "./c51_render_layout.ts"; +import { getViewer } from "./c32_session.ts"; +import { ADMIN_USERNAME } from "./c31_site_config.ts"; +import { + listProposals, + getProposal, + setProposalStatus, +} from "./c13_database.ts"; +import { + renderAdminProposalList, + renderAdminProposalDetail, + renderAdminGate, +} from "./c51_render_edit.ts"; + +const requireAdmin = async (req: Request): Promise<{ viewer: string } | Response> => { + const viewer = await getViewer(req); + if (viewer !== ADMIN_USERNAME) { + const html = await renderAdminGate(viewer); + return htmlResponse(html, viewer ? 403 : 401); + } + return { viewer }; +}; + +// GET /admin/proposals +export const adminProposalsHandler = async (req: Request): Promise => { + const gate = await requireAdmin(req); + if (gate instanceof Response) return gate; + const proposals = listProposals(); + const html = await renderAdminProposalList(proposals, gate.viewer); + return htmlResponse(html); +}; + +// GET /admin/proposals/:id +export const adminProposalDetailHandler = async ( + req: Request & { params: { id: string } }, +): Promise => { + const gate = await requireAdmin(req); + if (gate instanceof Response) return gate; + const id = parseInt(req.params.id, 10); + if (!Number.isInteger(id) || id <= 0) { + const html = await renderNotFound(`/admin/proposals/${req.params.id}`); + return htmlResponse(html, 404); + } + const proposal = getProposal(id); + if (!proposal) { + const html = await renderNotFound(`/admin/proposals/${id}`); + return htmlResponse(html, 404); + } + const file = Bun.file(`./${proposal.editPath}`); + const currentBody = (await file.exists()) ? await file.text() : ""; + const html = await renderAdminProposalDetail(proposal, currentBody, gate.viewer); + return htmlResponse(html); +}; + +// POST /admin/proposals/:id/approve +export const adminProposalApproveHandler = async ( + req: Request & { params: { id: string } }, +): Promise => { + const gate = await requireAdmin(req); + if (gate instanceof Response) return gate; + const id = parseInt(req.params.id, 10); + if (!Number.isInteger(id) || id <= 0) return new Response("bad id", { status: 400 }); + if (!getProposal(id)) return new Response("not found", { status: 404 }); + setProposalStatus(id, "approved", gate.viewer); + return new Response(null, { status: 303, headers: { Location: `/admin/proposals/${id}` } }); +}; + +// POST /admin/proposals/:id/reject +export const adminProposalRejectHandler = async ( + req: Request & { params: { id: string } }, +): Promise => { + const gate = await requireAdmin(req); + if (gate instanceof Response) return gate; + const id = parseInt(req.params.id, 10); + if (!Number.isInteger(id) || id <= 0) return new Response("bad id", { status: 400 }); + if (!getProposal(id)) return new Response("not found", { status: 404 }); + const form = await req.formData(); + const reason = (form.get("reason") ?? "").toString().slice(0, 500) || null; + setProposalStatus(id, "rejected", gate.viewer, reason); + return new Response(null, { status: 303, headers: { Location: `/admin/proposals/${id}` } }); +}; + +// GET /admin/proposals/:id/patch +// +// Returns the proposed body as a downloadable .md file with a header +// comment naming the target path. Owner saves it to dev's working +// tree, runs `git diff` to review, commits, deploys. +export const adminProposalPatchHandler = async ( + req: Request & { params: { id: string } }, +): Promise => { + const gate = await requireAdmin(req); + if (gate instanceof Response) return gate; + const id = parseInt(req.params.id, 10); + if (!Number.isInteger(id) || id <= 0) return new Response("bad id", { status: 400 }); + const proposal = getProposal(id); + if (!proposal) return new Response("not found", { status: 404 }); + const filename = proposal.editPath.split("/").pop() ?? `proposal-${id}.md`; + const header = `\n`; + return new Response(header + proposal.body, { + headers: { + "Content-Type": "text/markdown; charset=utf-8", + "Content-Disposition": `attachment; filename="${filename}"`, + }, + }); +}; diff --git a/src/c21_handlers_auth.ts b/src/c21_handlers_auth.ts index d52b5342163f503f7ab90c68123123c38b0c9cd0..307c49ecec5c70b5bfa2d0b50e35eff1abc77d07 100644 --- a/src/c21_handlers_auth.ts +++ b/src/c21_handlers_auth.ts @@ -20,20 +20,36 @@ const CALLBACK_URL = `${BASE_URL}/auth/github/callback`; const CLEAR_OAUTH_STATE = "tdd_oauth_state=; Path=/auth; HttpOnly; Secure; SameSite=Lax; Max-Age=0"; +const CLEAR_OAUTH_RETURN = + "tdd_oauth_return=; Path=/auth; HttpOnly; Secure; SameSite=Lax; Max-Age=0"; -export const startGithubOauth = (): Response => { +// Same-origin internal path. Anything that doesn't start with a single +// "/" or that contains "//" / ":" is rejected to prevent open-redirect. +const isSafeReturnTo = (s: string): boolean => + s.startsWith("/") && !s.startsWith("//") && !s.includes("\n") && !s.includes("\r") && s.length < 1024; + +export const startGithubOauth = (req?: Request): Response => { if (!github.isConfigured() || !forgejo.isConfigured()) { - // errorPage is async; we wrap below. return new Response("registration is not configured on this server", { status: 503 }); } const nonce = randomHex(16); - return new Response(null, { - status: 302, - headers: { - Location: github.authorizeUrl(nonce, CALLBACK_URL), - "Set-Cookie": `tdd_oauth_state=${nonce}; Path=/auth; HttpOnly; Secure; SameSite=Lax; Max-Age=600`, - }, - }); + const headers = new Headers(); + headers.append("Set-Cookie", `tdd_oauth_state=${nonce}; Path=/auth; HttpOnly; Secure; SameSite=Lax; Max-Age=600`); + + // Optional ?to= query — set a return cookie the callback + // honours after a successful sign-in. Used by /edit and /admin + // links so the user lands back where they came from. + if (req) { + const to = new URL(req.url).searchParams.get("to"); + if (to && isSafeReturnTo(to)) { + headers.append( + "Set-Cookie", + `tdd_oauth_return=${encodeURIComponent(to)}; Path=/auth; HttpOnly; Secure; SameSite=Lax; Max-Age=600`, + ); + } + } + headers.set("Location", github.authorizeUrl(nonce, CALLBACK_URL)); + return new Response(null, { status: 302, headers }); }; const welcomeBody = (reg: forgejo.AgentRegistration): string => { @@ -102,18 +118,22 @@ export const handleGithubCallback = async (req: Request): Promise => { // Login vs register: if the user already exists in Forgejo, this // is a returning visitor — set the session cookie, redirect to - // their dashboard, don't rotate their token. + // their dashboard (or to the cookie-stored returnTo path, when one + // was set by /auth/github/start?to=...), don't rotate their token. const isExisting = await forgejo.userExists(username); const sessionToken = await signSession(username); const sessionCookie = sessionCookieHeader(sessionToken, SESSION_TTL_SEC); + const returnToRaw = cookies.tdd_oauth_return ? decodeURIComponent(cookies.tdd_oauth_return) : null; + const returnTo = returnToRaw && isSafeReturnTo(returnToRaw) ? returnToRaw : null; if (isExisting) { return new Response(null, { status: 302, headers: new Headers([ - ["Location", `/agents/${username}`], + ["Location", returnTo ?? `/agents/${username}`], ["Set-Cookie", sessionCookie], ["Set-Cookie", CLEAR_OAUTH_STATE], + ["Set-Cookie", CLEAR_OAUTH_RETURN], ]), }); } @@ -140,6 +160,7 @@ export const handleGithubCallback = async (req: Request): Promise => { ["Content-Type", "text/html; charset=utf-8"], ["Set-Cookie", sessionCookie], ["Set-Cookie", CLEAR_OAUTH_STATE], + ["Set-Cookie", CLEAR_OAUTH_RETURN], ]), }); }; diff --git a/src/c21_handlers_edit.ts b/src/c21_handlers_edit.ts new file mode 100644 index 0000000000000000000000000000000000000000..1523e727fa322622e50bad60e9bd0eba33fc68b2 --- /dev/null +++ b/src/c21_handlers_edit.ts @@ -0,0 +1,78 @@ +// c21 — handlers: the self-hosted editor. Replaces "edit this page on +// GitHub" with our own form. Identity still comes from GitHub OAuth +// (handled by c21_handlers_auth) but every edit lands as a *proposal* +// in our SQLite store; the live page does not change until the owner +// approves and applies the patch via git on dev. This is the +// guarantee the user asked for: edits never bypass deploy. + +import { renderNotFound, htmlResponse } from "./c51_render_layout.ts"; +import { getViewer } from "./c32_session.ts"; +import { resolveEdit } from "./c32_edit_resolve.ts"; +import { + parseProposalSubmission, + isNoOpProposal, + ProposalValidationError, +} from "./c31_proposals.ts"; +import { createProposal } from "./c13_database.ts"; +import { + renderEditFormPage, + renderEditLoginWall, + renderEditThanks, +} from "./c51_render_edit.ts"; + +const readCurrentBody = async (filePath: string): Promise => { + const file = Bun.file(`./${filePath}`); + if (!(await file.exists())) return null; + return await file.text(); +}; + +// GET + POST /edit/:section/:slug — single handler, branches on method. +export const editPageHandler = async (req: Request & { params: { section: string; slug: string } }): Promise => { + const resolved = resolveEdit(req.params.section, req.params.slug); + if (!resolved) { + const html = await renderNotFound(`/edit/${req.params.section}/${req.params.slug}`); + return htmlResponse(html, 404); + } + + const viewer = await getViewer(req); + if (!viewer) { + const html = await renderEditLoginWall(resolved); + return htmlResponse(html, 401); + } + + if (req.method === "POST") { + const form = await req.formData(); + const body = (form.get("body") ?? "").toString(); + const current = (await readCurrentBody(resolved.filePath)) ?? ""; + if (isNoOpProposal(current, body)) { + // No diff — no point queuing it. Send the user back to the form + // without creating a row. + return new Response(null, { + status: 303, + headers: { Location: `/edit/${resolved.section}/${resolved.slug}` }, + }); + } + let id: number; + try { + const parsed = parseProposalSubmission({ + pageUrl: resolved.pageUrl, + editPath: resolved.filePath, + title: resolved.title, + body, + author: viewer, + }); + id = createProposal(parsed); + } catch (e) { + if (e instanceof ProposalValidationError) { + return new Response(`proposal rejected: ${e.message}`, { status: 400 }); + } + throw e; + } + const html = await renderEditThanks(resolved, id); + return htmlResponse(html); + } + + const current = (await readCurrentBody(resolved.filePath)) ?? ""; + const html = await renderEditFormPage(resolved, current, viewer); + return htmlResponse(html); +}; diff --git a/src/c31_proposals.test.ts b/src/c31_proposals.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..3bd4a756144e177bc994cb2e2c07bd5ddd9a759b --- /dev/null +++ b/src/c31_proposals.test.ts @@ -0,0 +1,54 @@ +import { test, expect } from "bun:test"; +import { + parseProposalSubmission, + isNoOpProposal, + ProposalValidationError, + MAX_PROPOSAL_BODY_BYTES, +} from "./c31_proposals.ts"; + +const valid = { + pageUrl: "/sama/sorted", + editPath: "content/sama/sorted.md", + title: "S — Sorted", + body: "# Sorted\n\nNew text.", + author: "octocat", +}; + +test("accepts a well-formed submission", () => { + const out = parseProposalSubmission(valid); + expect(out.body).toBe(valid.body); + expect(out.author).toBe("octocat"); +}); + +test("rejects non-object input", () => { + expect(() => parseProposalSubmission(null)).toThrow(ProposalValidationError); + expect(() => parseProposalSubmission("string")).toThrow(ProposalValidationError); +}); + +test("rejects missing fields", () => { + expect(() => parseProposalSubmission({ ...valid, body: undefined })).toThrow(ProposalValidationError); + expect(() => parseProposalSubmission({ ...valid, author: 42 })).toThrow(ProposalValidationError); +}); + +test("rejects empty body", () => { + expect(() => parseProposalSubmission({ ...valid, body: " \n " })).toThrow(/empty/); +}); + +test("rejects empty author", () => { + expect(() => parseProposalSubmission({ ...valid, author: "" })).toThrow(/author/); +}); + +test("enforces the body size cap", () => { + const huge = "x".repeat(MAX_PROPOSAL_BODY_BYTES + 1); + expect(() => parseProposalSubmission({ ...valid, body: huge })).toThrow(/exceeds/); +}); + +test("accepts a body right at the size cap", () => { + const limit = "x".repeat(MAX_PROPOSAL_BODY_BYTES); + expect(parseProposalSubmission({ ...valid, body: limit }).body.length).toBe(MAX_PROPOSAL_BODY_BYTES); +}); + +test("isNoOpProposal flags identical bodies", () => { + expect(isNoOpProposal("a", "a")).toBe(true); + expect(isNoOpProposal("a", "a ")).toBe(false); +}); diff --git a/src/c31_proposals.ts b/src/c31_proposals.ts new file mode 100644 index 0000000000000000000000000000000000000000..a127d0e5715a8087ae667df902dda7c18db21af2 --- /dev/null +++ b/src/c31_proposals.ts @@ -0,0 +1,63 @@ +// c31 — model: parser for proposal submissions. The DB row shape lives +// in c13_database.ts (ProposalRow / NewProposal); this file validates +// untyped form input before it reaches the DB. +// +// A proposal is a markdown-body edit suggestion for a doc page. We +// cap the body size and reject empty submissions so the proposals +// table stays bounded and admins aren't reviewing accidental no-ops. + +import type { NewProposal } from "./c13_database.ts"; + +export const MAX_PROPOSAL_BODY_BYTES = 256 * 1024; // 256 KB + +export interface ProposalSubmissionInput { + pageUrl: string; + editPath: string; + title: string; + body: string; + author: string; +} + +export class ProposalValidationError extends Error { + constructor(message: string) { + super(message); + this.name = "ProposalValidationError"; + } +} + +const requireString = (v: unknown, label: string): string => { + if (typeof v !== "string") throw new ProposalValidationError(`${label} must be a string`); + return v; +}; + +export const parseProposalSubmission = (input: unknown): NewProposal => { + if (input === null || typeof input !== "object") { + throw new ProposalValidationError("submission must be an object"); + } + const obj = input as Record; + const body = requireString(obj.body, "body"); + if (body.trim().length === 0) { + throw new ProposalValidationError("body cannot be empty"); + } + const bodyBytes = new TextEncoder().encode(body).length; + if (bodyBytes > MAX_PROPOSAL_BODY_BYTES) { + throw new ProposalValidationError(`body exceeds the ${MAX_PROPOSAL_BODY_BYTES / 1024} KB limit (got ${Math.round(bodyBytes / 1024)} KB)`); + } + const author = requireString(obj.author, "author"); + if (author.trim().length === 0) { + throw new ProposalValidationError("author cannot be empty"); + } + return { + pageUrl: requireString(obj.pageUrl, "pageUrl"), + editPath: requireString(obj.editPath, "editPath"), + title: requireString(obj.title, "title"), + body, + author, + }; +}; + +// Returns true when the proposed body is byte-identical to the +// current page body. Used by the edit handler to skip storing +// no-op submissions. +export const isNoOpProposal = (currentBody: string, proposedBody: string): boolean => + currentBody === proposedBody; diff --git a/src/c31_site_config.ts b/src/c31_site_config.ts index 5a06b9715a7056eb904844aac3fbbfccf098dc32..cf33a5054581045ca603e9fe6d4427ad6d2b0f24 100644 --- a/src/c31_site_config.ts +++ b/src/c31_site_config.ts @@ -8,3 +8,12 @@ export const LIVE_REPO_NAME = "tdd.md"; // Number of recent commits the live-reports view samples from the // in-container git-history bundle. export const LIVE_FETCH_COUNT = 100; + +// Owner / admin GitHub login. Gates /admin/* routes (proposal review). +// Override per-environment via TDD_ADMIN_USER if needed. +export const ADMIN_USERNAME = process.env.TDD_ADMIN_USER ?? "syntaxai"; + +// Self-hosted Forgejo mirror — what we link to when the docs layout +// says "view source on our git". The link goes to the canonical +// rendered file in the mirror, NOT to a web editor (we have our own). +export const SELF_HOSTED_REPO_BLOB_BASE = "https://git.tdd.md/syntaxai/tdd.md/src/branch/main"; diff --git a/src/c32_edit_resolve.test.ts b/src/c32_edit_resolve.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..695878ee6c66e473427daaf8f07c1618662234c5 --- /dev/null +++ b/src/c32_edit_resolve.test.ts @@ -0,0 +1,44 @@ +import { test, expect } from "bun:test"; +import { resolveEdit } from "./c32_edit_resolve.ts"; + +test("resolves an existing sama page", () => { + const r = resolveEdit("sama", "sorted"); + expect(r).not.toBeNull(); + expect(r?.pageUrl).toBe("/sama/sorted"); + expect(r?.filePath).toBe("content/sama/sorted.md"); + expect(r?.title).toMatch(/Sorted/); +}); + +test("resolves an existing guide", () => { + const r = resolveEdit("guides", "claude-code"); + expect(r).not.toBeNull(); + expect(r?.filePath).toBe("content/guides/claude-code.md"); +}); + +test("resolves an existing blog post", () => { + const r = resolveEdit("blog", "from-rules-to-checks"); + expect(r).not.toBeNull(); + expect(r?.filePath).toBe("content/blog/from-rules-to-checks.md"); +}); + +test("returns null for unknown section", () => { + expect(resolveEdit("admin", "x")).toBeNull(); + expect(resolveEdit("etc", "passwd")).toBeNull(); +}); + +test("returns null for unknown slug in a known section", () => { + expect(resolveEdit("sama", "nonexistent-discipline")).toBeNull(); +}); + +test("rejects path traversal in slug", () => { + expect(resolveEdit("sama", "../sorted")).toBeNull(); + expect(resolveEdit("sama", "..")).toBeNull(); + expect(resolveEdit("sama", "/etc/passwd")).toBeNull(); +}); + +test("rejects unsafe slug shapes", () => { + expect(resolveEdit("sama", "Sorted")).toBeNull(); // uppercase + expect(resolveEdit("sama", "")).toBeNull(); + expect(resolveEdit("sama", "with space")).toBeNull(); + expect(resolveEdit("sama", "-leading-dash")).toBeNull(); +}); diff --git a/src/c32_edit_resolve.ts b/src/c32_edit_resolve.ts new file mode 100644 index 0000000000000000000000000000000000000000..bbfd1df0630700923f682235e811443a409cc69a --- /dev/null +++ b/src/c32_edit_resolve.ts @@ -0,0 +1,58 @@ +// c32 — pure logic: given a (section, slug) tuple from a /edit/<...> +// URL, resolve it to the editable resource (page URL, file path on +// disk, page title) — or return null if the combination doesn't map +// to an existing doc page. Pure: no I/O. The handler does the actual +// fs read for the current body. +// +// Editable sections are the ones that already have a registry: sama, +// guides, blog. Slug must match an entry in the corresponding registry +// — this prevents arbitrary file writes via /edit/../../etc/passwd +// style inputs. + +import { ALL_SAMA } from "./c31_sama.ts"; +import { ALL_GUIDES } from "./c31_guides.ts"; +import { ALL_POSTS } from "./c31_blog.ts"; + +export type EditableSection = "sama" | "guides" | "blog"; + +export interface ResolvedEdit { + section: EditableSection; + slug: string; + pageUrl: string; + filePath: string; + title: string; +} + +const SECTIONS = new Set(["sama", "guides", "blog"]); + +const isValidSection = (s: string): s is EditableSection => SECTIONS.has(s as EditableSection); + +const SAFE_SLUG = /^[a-z0-9][a-z0-9-]*$/; + +const lookupTitle = (section: EditableSection, slug: string): string | null => { + if (section === "sama") { + const e = ALL_SAMA.find((d) => d.slug === slug); + return e ? `${e.letter} — ${e.title}` : null; + } + if (section === "guides") { + const e = ALL_GUIDES.find((g) => g.slug === slug); + return e?.title ?? null; + } + // blog + const e = ALL_POSTS.find((p) => p.slug === slug); + return e?.title ?? null; +}; + +export const resolveEdit = (section: string, slug: string): ResolvedEdit | null => { + if (!isValidSection(section)) return null; + if (!SAFE_SLUG.test(slug)) return null; + const title = lookupTitle(section, slug); + if (title === null) return null; + return { + section, + slug, + pageUrl: `/${section}/${slug}`, + filePath: `content/${section}/${slug}.md`, + title, + }; +}; diff --git a/src/c51_render_docs_layout.ts b/src/c51_render_docs_layout.ts index f42fd5d4f7dcdb8f522a07da60a5424f86cde2eb..e19c200959b81cacafe1982650d3fcf06e098bd6 100644 --- a/src/c51_render_docs_layout.ts +++ b/src/c51_render_docs_layout.ts @@ -19,8 +19,7 @@ import { escape, type PageOptions, } from "./c51_render_layout.ts"; - -const REPO_EDIT_BASE = "https://github.com/syntaxai/tdd.md/edit/main"; +import { SELF_HOSTED_REPO_BLOB_BASE } from "./c31_site_config.ts"; export interface DocsPageOptions extends Omit { // The route path the user is on, e.g. "/sama/sorted". Used to @@ -67,10 +66,23 @@ const renderAnchorRail = (anchors: Anchor[]): string => { `; }; +// Derive (section, slug) from a content/
/.md editPath. +// Returns null when the path doesn't follow the convention (in which +// case there's no editor route to link to). +const sectionSlugFromEditPath = (editPath: string): { section: string; slug: string } | null => { + const m = /^content\/([a-z]+)\/([a-z0-9][a-z0-9-]*)\.md$/.exec(editPath); + return m ? { section: m[1]!, slug: m[2]! } : null; +}; + const renderEditLink = (editPath: string | null): string => { if (!editPath) return ""; - const url = `${REPO_EDIT_BASE}/${editPath}`; - return `

edit this page on GitHub →

`; + const sourceUrl = `${SELF_HOSTED_REPO_BLOB_BASE}/${editPath}`; + const ss = sectionSlugFromEditPath(editPath); + const editHref = ss ? `/edit/${ss.section}/${ss.slug}` : null; + const editAnchor = editHref + ? `propose an edit → · ` + : ""; + return `

${editAnchor}view source on git.tdd.md →

`; }; const renderPrevNext = (loc: ResolvedDocsLocation | null): string => { diff --git a/src/c51_render_edit.ts b/src/c51_render_edit.ts new file mode 100644 index 0000000000000000000000000000000000000000..6cf61e12b92eed1556a160341554e4bb01a99390 --- /dev/null +++ b/src/c51_render_edit.ts @@ -0,0 +1,196 @@ +// c51 (edit) — UI: edit-form, login-required prompt, admin proposal +// list, and side-by-side current vs proposed view. Composes the +// docs layout's chrome via renderPage with bodyHtml so the form +// can use real
elements (markdown would escape them). + +import { + renderPage, + escape, +} from "./c51_render_layout.ts"; +import type { ProposalRow } from "./c13_database.ts"; +import type { ResolvedEdit } from "./c32_edit_resolve.ts"; + +const ts = (n: number): string => new Date(n).toISOString().replace("T", " ").slice(0, 19) + " UTC"; + +const layoutWrap = (innerHtml: string): string => + `
${innerHtml}
`; + +// Override the standard
: the edit experience needs +// full-width form controls, not the doc-layout's three columns. +const editBodyClass = "edit-body"; + +// -------- /edit/:section/:slug — form for a logged-in user -------- + +export const renderEditFormPage = async ( + resolved: ResolvedEdit, + currentBody: string, + viewer: string, +): Promise => { + const inner = `

edit · ${escape(resolved.title)}

+

+ Editing ${escape(resolved.filePath)} as ${escape(viewer)}. + Submitting saves a proposal — the live page does not change. + view the live page · + log out +

+ + +
+ + cancel +
+ +

+ Proposals are queued for review at /admin/proposals. The owner downloads + the proposed body, applies it via git, and the next deploy publishes the change. No edits + bypass git or the deploy pipeline. +

`; + return renderPage({ + title: `edit · ${resolved.title} — tdd.md`, + bodyHtml: layoutWrap(inner), + description: `Suggest an edit to ${resolved.title} on tdd.md. Proposals are queued for owner review and never bypass git.`, + ogPath: `https://tdd.md/edit/${resolved.section}/${resolved.slug}`, + noindex: true, + bodyClass: editBodyClass, + }); +}; + +// -------- login wall before the form -------- + +export const renderEditLoginWall = async ( + resolved: ResolvedEdit, +): Promise => { + const returnTo = `/edit/${resolved.section}/${resolved.slug}`; + const inner = `

edit · ${escape(resolved.title)}

+

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.

+

+

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.

+

← back to the page

`; + return renderPage({ + title: `sign in to edit · ${resolved.title} — tdd.md`, + bodyHtml: layoutWrap(inner), + description: `Sign in via GitHub to propose an edit to ${resolved.title} on tdd.md.`, + noindex: true, + bodyClass: editBodyClass, + }); +}; + +// -------- thank-you page after submit -------- + +export const renderEditThanks = async ( + resolved: ResolvedEdit, + proposalId: number, +): Promise => { + const inner = `

thanks — proposal #${proposalId} queued

+

Your edit to ${escape(resolved.pageUrl)} is in the review queue.

+

The owner will review pending proposals at /admin/proposals. The live page won't change until they approve and apply the patch via git.

+

← back to the page · propose another edit

`; + return renderPage({ + title: `proposal #${proposalId} queued — tdd.md`, + bodyHtml: layoutWrap(inner), + noindex: true, + bodyClass: editBodyClass, + }); +}; + +// -------- admin: proposal list -------- + +const statusBadge = (s: ProposalRow["status"]): string => { + const cls = s === "approved" ? "edit-status edit-status-approved" + : s === "rejected" ? "edit-status edit-status-rejected" + : "edit-status edit-status-pending"; + return `${s}`; +}; + +export const renderAdminProposalList = async ( + proposals: ProposalRow[], + viewer: string, +): Promise => { + const rows = proposals.length === 0 + ? `no proposals yet` + : proposals.map((p) => ` + #${p.id} + ${escape(p.pageUrl)} + ${escape(p.author)} + ${escape(ts(p.submittedAt))} + ${statusBadge(p.status)} + review → +`).join("\n"); + const inner = `

proposal queue

+

${proposals.length} proposal${proposals.length === 1 ? "" : "s"} · signed in as ${escape(viewer)}.

+ + + ${rows} +
idpageauthorsubmittedstatus
`; + return renderPage({ + title: "proposals — tdd.md", + bodyHtml: layoutWrap(inner), + noindex: true, + bodyClass: editBodyClass, + }); +}; + +// -------- admin: single proposal review (current vs proposed) -------- + +export const renderAdminProposalDetail = async ( + proposal: ProposalRow, + currentBody: string, + viewer: string, +): Promise => { + const reviewedLine = proposal.status === "pending" + ? "" + : `

${escape(proposal.status)} by ${escape(proposal.reviewedBy ?? "?")} at ${escape(ts(proposal.reviewedAt ?? 0))}${proposal.rejectReason ? ` · reason: ${escape(proposal.rejectReason)}` : ""}

`; + const actions = proposal.status === "pending" + ? `
+ +
+
+ + +
+download patch (.md)` + : `download patch (.md)`; + const inner = `

proposal #${proposal.id} · ${escape(proposal.title)}

+

+ Page: ${escape(proposal.pageUrl)} · + File: ${escape(proposal.editPath)} · + Author: ${escape(proposal.author)} · + Submitted: ${escape(ts(proposal.submittedAt))} · + Status: ${statusBadge(proposal.status)} +

+${reviewedLine} +

Reviewing as ${escape(viewer)}. Approving sets the status; the live page does not change until you commit the patch on dev and run a deploy.

+
${actions}
+

diff (current ⇢ proposed)

+
+
+

current

+
${escape(currentBody)}
+
+
+

proposed

+
${escape(proposal.body)}
+
+
+

← back to queue

`; + return renderPage({ + title: `proposal #${proposal.id} — tdd.md`, + bodyHtml: layoutWrap(inner), + noindex: true, + bodyClass: editBodyClass, + }); +}; + +// -------- admin gate page (not the owner) -------- + +export const renderAdminGate = async (viewer: string | null): Promise => { + const inner = viewer + ? `

admin · access denied

Signed in as ${escape(viewer)}, but this area is owner-only. ← back home

` + : `

admin · sign in

`; + return renderPage({ + title: "admin — tdd.md", + bodyHtml: layoutWrap(inner), + noindex: true, + bodyClass: editBodyClass, + }); +};