/** * SRS Worker entry: fetch routes + DST-proof cron dispatch. * * Direction of truth: D1 owns SRS state; GitHub issues own the catalog; * the bundled schedule.json owns the calendar. Every handler writes D1 * first and runs GitHub/Project side effects afterwards via ctx.waitUntil — * a mirror failure logs a warning and never loses a state write. * * Writes are owner-only (webhook author check, HMAC one-tap links, bearer * admin routes); everything else is read-only public. */ import { reconcileCatalog } from "./catalog.ts"; import { gateBadge, heatmapChart, heatmapPng, ladderChart, progressChart } from "./charts.ts"; import { sendDigest } from "./digest.ts"; import { createReviewIssue, scoreReview } from "./gate.ts"; import { type GitHub, github } from "./github.ts"; import { timingSafeEqual, verifyLink, verifyWebhook } from "./links.ts"; import { type Mirror, projectMirror } from "./mirror.ts"; import { CAMPAIGN_START, type LogOutcome, type Stage, daysBetween, dueToday, etDate, etHour, getProblem, logAttempt, weekdayOf, } from "./srs.ts"; import { buildStats } from "./stats.ts"; // ── shared side effects after a state write ────────────────────── /** * Mirror one logged attempt into GitHub: the Project's Target Date always; on a * first-ever pass also close the problem's sub-issue (comment first — repo * convention). Runs inside ctx.waitUntil; failures are warnings. */ async function mirrorOutcome(env: Env, outcome: LogOutcome, source: string): Promise { if (outcome.error || outcome.duplicate) return; const gh = github(env.GH_PAT, env.REPO); const mirror = projectMirror(gh, env.REPO.split("/")[0]!); await mirror.setTargetDate(outcome.issue, outcome.next_review); if (outcome.first && outcome.result === "pass") { try { await gh.closeIssue( outcome.issue, `First attempt passed (${outcome.kind}, via ${source}) — entering the review ladder at +3. Logged by the SRS Worker.`, ); } catch (err) { console.warn(`close #${outcome.issue}: ${err}`); } } } function outcomeLine(o: LogOutcome): string { if (o.error) return o.error; if (o.duplicate) return `LC ${o.lc}: already logged today — nothing changed`; return ( `LC ${o.lc}: ${o.kind} ${o.result} → stage ${o.stage}` + (o.next_review ? `, review ${o.next_review}` : " — retired 🎉") ); } // ── one-tap email links ────────────────────────────────────────── function page(title: string, body: string, status = 200): Response { return new Response( `

${title}

${body}

`, { status, headers: { "content-type": "text/html; charset=utf-8" } }, ); } async function handleTap(request: Request, env: Env, ctx: ExecutionContext): Promise { const url = new URL(request.url); const lc = Number(url.searchParams.get("p")); const result = url.searchParams.get("r"); const date = url.searchParams.get("d") ?? ""; const sig = url.searchParams.get("sig") ?? ""; if (!Number.isFinite(lc) || (result !== "pass" && result !== "fail") || !date) { return page("Bad link", "Missing or malformed parameters.", 400); } if (!(await verifyLink(env.LINK_KEY, lc, result, date, sig))) { return page("Bad signature", "This link was not signed by the SRS.", 403); } const today = etDate(new Date()); if (daysBetween(date, today) > 3 || daysBetween(today, date) > 1) { return page("Link expired", "Older than 3 days — log it with a /done comment instead.", 410); } const outcome = await logAttempt(env.DB, { lc, date, result, source: "email" }); if (outcome.error) return page("Not logged", outcome.error, 422); if (outcome.duplicate) { return page("Already logged ✓", `LC ${lc} was already recorded today. Nothing changed.`); } ctx.waitUntil(mirrorOutcome(env, outcome, "email")); return page( result === "pass" ? "Logged ✅" : "Logged — back to +3", outcomeLine(outcome), ); } // ── GitHub webhook ─────────────────────────────────────────────── interface CommentEvent { action: string; issue: { number: number; title: string; labels: { name: string }[] }; comment: { id: number; body: string; user: { login: string } }; } async function handleWebhook(request: Request, env: Env, ctx: ExecutionContext): Promise { const raw = await request.arrayBuffer(); const ok = await verifyWebhook(env.WEBHOOK_SECRET, raw, request.headers.get("x-hub-signature-256")); if (!ok) return new Response("bad signature", { status: 401 }); const event = request.headers.get("x-github-event"); const payload: unknown = JSON.parse(new TextDecoder().decode(raw)); const owner = env.REPO.split("/")[0]!; const today = etDate(new Date()); if (event === "issues") { if (!payload || typeof payload !== "object" || !("action" in payload) || !("issue" in payload)) { return new Response("ignored"); } const p = payload as { action: string; issue: { number: number; labels: { name: string }[] } }; if (p.action === "closed" && p.issue.labels.some((l) => l.name === "review")) { const gh = github(env.GH_PAT, env.REPO); const report = await scoreReview(env, gh, p.issue.number, today); return Response.json(report); } return new Response("ignored"); } if (event !== "issue_comment") return new Response("ignored"); const p = payload as CommentEvent; if (p.action !== "created") return new Response("ignored"); if (p.comment.user.login !== owner) return new Response("ignored (owner only)"); const commands: { lc: number; result: "pass" | "fail" }[] = []; const errors: string[] = []; for (const rawLine of p.comment.body.split("\n")) { const line = rawLine.trim(); if (!line.startsWith("/done")) continue; const m = line.match(/^\/done\s+(\d+)\s+(pass|fail)\s*$/); if (m) commands.push({ lc: Number(m[1]), result: m[2] as "pass" | "fail" }); else errors.push(`cannot parse \`${line}\` — expected \`/done \``); } if (commands.length === 0 && errors.length === 0) return new Response("no commands"); // Comments on a review issue log its gate problems as kind='gate'. const gateRow = await env.DB.prepare("SELECT problems FROM gates WHERE issue = ?") .bind(p.issue.number) .first<{ problems: string }>(); const gateLcs = new Set(gateRow ? JSON.parse(gateRow.problems) : []); const outcomes: LogOutcome[] = []; for (const cmd of commands) { const outcome = await logAttempt(env.DB, { lc: cmd.lc, date: today, result: cmd.result, source: "webhook", gate: gateLcs.has(cmd.lc), }); if (outcome.error) errors.push(outcome.error); else outcomes.push(outcome); } const gh = github(env.GH_PAT, env.REPO); ctx.waitUntil( (async () => { for (const outcome of outcomes) await mirrorOutcome(env, outcome, "webhook"); try { if (errors.length === 0) { await gh.react(p.comment.id, "+1"); } else { await gh.comment( p.issue.number, `Could not log everything:\n\n${errors.map((e) => `- ${e}`).join("\n")}` + (outcomes.length ? `\n\nApplied anyway:\n\n${outcomes.map((o) => `- ${outcomeLine(o)}`).join("\n")}` : ""), ); } } catch (err) { console.warn(`webhook feedback: ${err}`); } })(), ); return Response.json({ applied: outcomes.map(outcomeLine), errors }); } // ── admin (bearer LINK_KEY, timing-safe) ───────────────────────── async function adminAuthorized(request: Request, env: Env): Promise { const header = request.headers.get("authorization") ?? ""; if (!header.startsWith("Bearer ")) return false; return timingSafeEqual(header.slice(7), env.LINK_KEY); } /** * Solutions landing in `work/` are the third way a problem gets solved, after * the digest tap and a /done comment. `close-solved` posts its whole * implemented set here on every push to main, each entry tagged with the * BUCKET the file sits in — 1 for the first solve, 3 and 7 for the blind * re-solves the picker scaffolds. The bucket names the ladder rung the file * settles, and logAttempt writes only when the problem is standing on that * rung, so this is a total recompute like the reconcilers that call it: a * re-run changes nothing, and a pushed re-solve advances +3 → +7 instead of * being invisible. * * Closing the sub-issue stays with close-solved: it is the side that knows * which files at which commit, so this path mirrors Project fields only. * Charts read D1 live, so a push moves them within the 5-minute cache. */ const RUNG_OF_BUCKET: Record = { 1: "new", 3: "+3", 7: "+7" }; async function handleSolved(request: Request, env: Env, date: string): Promise { const body: unknown = await request.json().catch(() => null); const raw: unknown = body && typeof body === "object" && "solved" in body ? body.solved : null; const shape = 'expected {"solved": [{"lc": , "bucket": 1|3|7}, ...]}'; if (!Array.isArray(raw)) return new Response(shape, { status: 400 }); const entries: { lc: number; rung: Stage; bucket: number }[] = []; for (const item of raw) { if (!item || typeof item !== "object") return new Response(shape, { status: 400 }); const lc: unknown = "lc" in item ? item.lc : null; const bucket: unknown = "bucket" in item ? item.bucket : null; if (typeof lc !== "number" || !Number.isFinite(lc)) { return new Response(`not an LC number: ${JSON.stringify(lc)}`, { status: 400 }); } const rung = typeof bucket === "number" ? RUNG_OF_BUCKET[bucket] : undefined; if (rung === undefined) return new Response(`not a work/ bucket: ${JSON.stringify(bucket)}`, { status: 400 }); entries.push({ lc, rung, bucket }); } const dry = new URL(request.url).searchParams.get("dry") === "1"; const logged: string[] = []; const skipped: string[] = []; let mirror: Mirror | undefined; for (const { lc, rung, bucket } of entries) { if (dry) { const p = await getProblem(env.DB, lc); if (!p) skipped.push(`LC ${lc} is not in the curriculum`); else if (p.stage !== rung) skipped.push(`LC ${lc}: work/${bucket} settles ${rung}, stage is ${p.stage}`); else if (rung === "new") logged.push(`LC ${lc}: would enter the ladder at +3`); else logged.push(`LC ${lc}: would pass ${rung} and move on`); continue; } const outcome = await logAttempt(env.DB, { lc, date, result: "pass", source: "commit", rung }); if (outcome.error) skipped.push(outcome.error); else if (outcome.duplicate) { skipped.push(`LC ${lc}: work/${bucket} settles ${rung}, stage is ${outcome.stage}`); } else { logged.push(outcomeLine(outcome)); // One mirror for the batch: field IDs resolve once, not per problem. mirror ??= projectMirror(github(env.GH_PAT, env.REPO), env.REPO.split("/")[0]!); await mirror.setTargetDate(outcome.issue, outcome.next_review); } } return Response.json({ date, logged, skipped, warnings: mirror?.warnings ?? [] }); } async function handleAdmin(request: Request, env: Env, path: string): Promise { if (!(await adminAuthorized(request, env))) return new Response("unauthorized", { status: 401 }); const url = new URL(request.url); const date = url.searchParams.get("date") ?? etDate(new Date()); const gh = () => github(env.GH_PAT, env.REPO); if (path === "/admin/reconcile") { return Response.json(await reconcileCatalog(env.DB, gh())); } if (path === "/admin/solved") return await handleSolved(request, env, date); if (path === "/admin/digest") { const report = await sendDigest(env, date, { dry: url.searchParams.get("dry") === "1", force: url.searchParams.get("force") === "1", }); if (url.searchParams.get("dry") === "1") { return new Response(report.digest.html, { headers: { "content-type": "text/html" } }); } return Response.json({ sent: report.sent, reason: report.reason, subject: report.digest.subject }); } if (path === "/admin/review") { return Response.json(await createReviewIssue(env, gh(), date)); } // What is due on `date`, straight out of the ladder — the ONE answer to // "which problems are open today", so the daily review issue and the digest // cannot disagree. Read-only; the caller decides what to render. if (path === "/admin/due") { return Response.json(await dueToday(env.DB, date)); } return new Response("not found", { status: 404 }); } // ── router ─────────────────────────────────────────────────────── const SVG_HEADERS = { "content-type": "image/svg+xml", // GitHub Camo honors this; 5 minutes is the freshness floor for README charts. "cache-control": "public, max-age=300", }; // The digest embeds the raster heatmap; email clients refuse SVG entirely. const PNG_HEADERS = { "content-type": "image/png", "cache-control": "public, max-age=300", }; export default { async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { const url = new URL(request.url); const path = url.pathname; try { if (path === "/log" && request.method === "GET") return await handleTap(request, env, ctx); if (path === "/webhook/github" && request.method === "POST") { return await handleWebhook(request, env, ctx); } if (path.startsWith("/admin/") && request.method === "POST") { return await handleAdmin(request, env, path); } if (path === "/chart/progress.svg") { return new Response(await progressChart(env.DB), { headers: SVG_HEADERS }); } if (path === "/chart/ladder.svg") { return new Response(await ladderChart(env.DB), { headers: SVG_HEADERS }); } if (path === "/chart/heatmap.svg") { return new Response(await heatmapChart(env.DB, CAMPAIGN_START, 56), { headers: SVG_HEADERS }); } if (path === "/chart/heatmap.png") { return new Response(await heatmapPng(env.DB, CAMPAIGN_START, 56), { headers: PNG_HEADERS }); } if (path === "/badge/gate.svg") { return new Response(await gateBadge(env.DB), { headers: SVG_HEADERS }); } if (path === "/api/stats") { // The docs serve from both the Pages origin and the custom domain; // reflect the requesting origin only when it is on the allowlist. const allowed = env.DOCS_ORIGIN.split(",").map((o) => o.trim()); const origin = request.headers.get("origin"); const cors = { "access-control-allow-origin": origin && allowed.includes(origin) ? origin : allowed[0]!, "access-control-allow-methods": "GET", vary: "Origin", }; if (request.method === "OPTIONS") return new Response(null, { headers: cors }); const stats = await buildStats(env.DB, etDate(new Date())); return Response.json(stats, { headers: cors }); } return new Response("srs-api", { status: path === "/" ? 200 : 404 }); } catch (err) { console.error(`unhandled ${request.method} ${path}: ${err}`); return new Response("internal error", { status: 500 }); } }, /** * DST-proof cron dispatch: crons fire at both possible UTC hours; the * computed ET hour decides. 8 AM daily → catalog reconcile + digest; * midnight Saturday → review issue (so the 8 AM digest can link to it). */ async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise { const now = new Date(controller.scheduledTime); const hour = etHour(now); const date = etDate(now); if (hour === 8) { try { const report = await reconcileCatalog(env.DB, github(env.GH_PAT, env.REPO)); console.log(`catalog: ${report.topics} topics, ${report.problems} problems`); } catch (err) { console.warn(`catalog reconcile failed (digest still goes out): ${err}`); } const report = await sendDigest(env, date); console.log(`digest ${date}: ${report.reason}`); return; } if (hour === 0 && weekdayOf(date) === 6) { const report = await createReviewIssue(env, github(env.GH_PAT, env.REPO), date); console.log(`review issue ${date}: ${report.reason}`); return; } console.log(`cron at ET hour ${hour} on ${date}: no-op (DST guard)`); void ctx; }, };