diff --git a/plugins/canopy/skills/update-plan/SKILL.md b/plugins/canopy/skills/update-plan/SKILL.md index c4bab63..0721ab6 100644 --- a/plugins/canopy/skills/update-plan/SKILL.md +++ b/plugins/canopy/skills/update-plan/SKILL.md @@ -69,6 +69,9 @@ Once confirmed, make **exactly one** call: ## Hard rules (invariants) +- **Server-gated to `ADMIN_LOGINS`.** `update_plan` is only registered for admin principals — a + non-admin bearer doesn't have the tool at all (absent from `tools/list`, tool-not-found if called). + This skill's own instructions are a second layer, not the enforcement boundary. - **Explicit only.** Never fire without a direct admin ask. - **Read before write, every time** — step 1 is not optional, even for a small edit. - **Confirm the diff before writing** — no silent writes. diff --git a/src/auth/principal.ts b/src/auth/principal.ts index 15c6166..3abe623 100644 --- a/src/auth/principal.ts +++ b/src/auth/principal.ts @@ -9,6 +9,16 @@ export interface Principal { export type AppEnv = { Bindings: Env; Variables: { principal: Principal } }; +/** + * Is this login an admin? ADMIN_LOGINS is a comma-separated allowlist of GitHub + * logins permitted to run admin actions (e.g. the server-side backfill). An + * absent/empty var means nobody is an admin — fails closed. + */ +export function isAdmin(env: Env, login: string): boolean { + const allow = (env.ADMIN_LOGINS ?? "").split(",").map((s) => s.trim()).filter(Boolean); + return allow.includes(login); +} + // The only routes reachable without a session. Everything else is gated. const PUBLIC_PATHS = new Set(["/auth/login", "/auth/callback"]); diff --git a/src/auth/routes.ts b/src/auth/routes.ts index 0fb6a1e..167de48 100644 --- a/src/auth/routes.ts +++ b/src/auth/routes.ts @@ -1,6 +1,7 @@ import { Hono } from "hono"; import { setCookie, getCookie, deleteCookie } from "hono/cookie"; import type { AppEnv } from "./principal"; +import { isAdmin } from "./principal"; import { pkce, randomToken, hmacSeal, hmacUnseal } from "./crypto"; import { buildAuthorizeUrl, exchangeCode, getUser, isActiveOrgMember } from "./github"; import { createSession, setSessionCookie, readSessionCookie, deleteSession, clearSessionCookie } from "./session"; @@ -73,7 +74,7 @@ authApp.get("/callback", async (c) => { authApp.get("/me", async (c) => { const login = c.get("principal").login; const row = await first<{ name: string | null; avatar_url: string | null }>(c.env.DB, `SELECT name, avatar_url FROM users WHERE github_login = ?`, login); - return c.json({ login, name: row?.name ?? null, avatar_url: row?.avatar_url ?? null, org: SAPLING_ORG }); + return c.json({ login, name: row?.name ?? null, avatar_url: row?.avatar_url ?? null, org: SAPLING_ORG, admin: isAdmin(c.env, login) }); }); // GATED (by sessionGate in src/routes.ts): revoke this session. diff --git a/src/env.ts b/src/env.ts index 30755f8..1bbe49c 100644 --- a/src/env.ts +++ b/src/env.ts @@ -9,4 +9,5 @@ export interface Env { DEV_LOGIN?: string; // LOCAL DEV ONLY (set in .dev.vars): bypass OAuth, act as this seeded user. Never set in prod. AI?: Ai; // Workers AI binding: capture-time completed-PR summaries only, never at render. GITHUB_SERVICE_TOKEN?: string; // app-level token for the scheduled progress-cache recompute backstop; absent → scheduled() no-ops + ADMIN_LOGINS?: string; // comma-separated GitHub logins allowed to run admin actions (e.g. the server-side backfill) } diff --git a/src/mcp.ts b/src/mcp.ts index b608f11..5c40eda 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -3,6 +3,7 @@ import { createMcpHandler } from "agents/mcp"; import { z } from "zod"; import type { Env } from "./env"; import type { Principal } from "./auth/principal"; +import { isAdmin } from "./auth/principal"; import { get_doc, list_docs, get_feed, query } from "./tools/reads"; import { getMyWork, list_events } from "./tools/mywork"; import { ingestFeedEntry, ingestDocProposal, consume } from "./consumer"; @@ -145,23 +146,29 @@ export function buildCanopyMcpServer(env: Env, principal: Principal): McpServer async (payload) => runTool(() => consume(env.DB, IngestPayload.parse(payload), principal)), ); - server.tool( - "update_plan", - "ADMIN plan write: replace the roadmap narrative and create/update milestones (including status 'done') in one direct, non-destructively versioned write — same authored-write class as promote, NOT the ingestion gate. Milestones not listed are untouched. Use via the update-plan skill.", - { - narrative: z.string(), - milestones: z.array(z.object({ - id: z.number().int().optional(), - title: z.string(), - description: z.string().nullable().optional(), - phase: z.string().nullable().optional(), - target_date: z.string(), - status: z.enum(["upcoming", "in_progress", "done"]), - github_ref: z.union([z.number(), z.array(z.number())]).nullable().optional(), - })).default([]), - }, - async (input) => runTool(() => write_plan(env.DB, input as PlanWrite, principal.login)) - ); + // ADMIN-only: the plan write surface — non-admin principals don't even see the tool + // (conditional registration means it's absent from tools/list and calling it by + // name errors tool-not-found, since a fresh server is built per request with the + // principal already in scope). + if (isAdmin(env, principal.login)) { + server.tool( + "update_plan", + "ADMIN plan write: replace the roadmap narrative and create/update milestones (including status 'done') in one direct, non-destructively versioned write — same authored-write class as promote, NOT the ingestion gate. Milestones not listed are untouched. Use via the update-plan skill.", + { + narrative: z.string(), + milestones: z.array(z.object({ + id: z.number().int().optional(), + title: z.string(), + description: z.string().nullable().optional(), + phase: z.string().nullable().optional(), + target_date: z.string(), + status: z.enum(["upcoming", "in_progress", "done"]), + github_ref: z.union([z.number(), z.array(z.number())]).nullable().optional(), + })).default([]), + }, + async (input) => runTool(() => write_plan(env.DB, input as PlanWrite, principal.login)) + ); + } return server; } diff --git a/src/routes.ts b/src/routes.ts index 3a3fb58..731527a 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -1,9 +1,10 @@ import { Hono } from "hono"; import { IngestPayload } from "@shared/contract"; import type { AppEnv } from "./auth/principal"; -import { sessionGate } from "./auth/principal"; +import { sessionGate, isAdmin } from "./auth/principal"; import { authApp } from "./auth/routes"; import { consume } from "./consumer"; +import { runBackfill } from "./tools/backfill"; import { get_doc, list_docs, get_feed, query, list_needs_triage, list_adrs, list_milestone_proposals, list_proposals } from "./tools/reads"; import { promote_doc, ratify_adr, promote_milestone_proposal, reject_milestone_proposal, complete_milestone, reject_doc_version, reject_adr, resolve_triage, assign_triage, type AssignType } from "./tools/writes"; import { get_plan } from "./tools/plan"; @@ -219,6 +220,18 @@ app.post("/milestone-proposals/:id/reject", async (c) => { } }); +// ADMIN action (session-gated + admin-gated): server-side GitHub backfill. +// A computed/authored direct writer in the promote class — humans (admins) +// trigger it — but every captured event still funnels through the ingestEvent +// gate fn. Non-admins get 403; a missing service token/repo → 503 with the error. +app.post("/admin/backfill", async (c) => { + const login = c.get("principal").login; + if (!isAdmin(c.env, login)) return c.json({ error: "admin only" }, 403); + const res = await runBackfill(c.env, login); + if (!res.ok) return c.json({ error: res.error }, 503); + return c.json(res); +}); + // Human confirmation (session-gated): flip a live milestone to 'done'. app.post("/milestones/:id/complete", async (c) => { const id = Number(c.req.param("id")); diff --git a/src/tools/backfill.ts b/src/tools/backfill.ts new file mode 100644 index 0000000..914f975 --- /dev/null +++ b/src/tools/backfill.ts @@ -0,0 +1,220 @@ +import type { Env } from "../env"; +import { nowIso } from "../db"; +import { ingestEvent } from "../consumer"; +import { eventsFromDelivery } from "../webhook"; +import { type Summarizer, workersAiSummarizer, storePrSummary } from "./summarize"; +import { applyEventProgress } from "./progress"; + +// Admin-triggered server-side GitHub backfill. Unlike scripts/backfill-events.mjs +// (which signs synthetic webhook deliveries with the webhook secret), this runs +// INSIDE the Worker with GITHUB_SERVICE_TOKEN — the same token the scheduled() +// progress recompute uses — so it fetches GitHub REST directly, no webhook secret. +// +// It reconstructs the SAME deliveries the webhook would have received, reuses the +// PURE eventsFromDelivery() derivation (never duplicated here), post-maps each +// event's provenance to "backfill", and writes through the ONE gate fn ingestEvent +// — but with the ADMIN principal as the writer (an authenticated identity), not +// the fixed "github-webhook" string. Downstream projections (PR summaries, issue +// progress) mirror handleGithubWebhook, hung off newly-written events only. + +const GH_API = "application/vnd.github+json"; +const USER_AGENT = "canopy"; +const DAYS_BACK = 14; + +export interface BackfillResult { + ok: boolean; + error?: string; + captured: number; + unchanged: number; + prs: number; + issues: number; +} + +// Minimal typed views over the GitHub REST list items — only the fields the +// delivery synthesizers below read are modeled; everything else is ignored. +interface GhUserLite { + login: string; +} +interface GhMilestoneLite { + number: number; + open_issues?: number; + closed_issues?: number; +} +interface GhPrListItem { + number: number; + title: string; + body: string | null; + html_url: string; + merged_at: string | null; + closed_at: string | null; + updated_at: string; + user: GhUserLite; + milestone?: GhMilestoneLite | null; +} +interface GhIssueListItem { + number: number; + title: string; + html_url: string; + state: string; + updated_at: string; + user: GhUserLite; + assignees?: GhUserLite[]; + assignee?: GhUserLite | null; + labels?: (string | { name: string })[]; + milestone?: GhMilestoneLite | null; + pull_request?: unknown; // present only when the "issue" is really a PR +} + +// The `rel="next"` URL from a GitHub Link header, or null when there is no next page. +function nextLink(res: Response): string | null { + const link = res.headers.get("link"); + const next = link?.split(",").find((part) => part.includes('rel="next"')); + return next ? (next.match(/<([^>]+)>/)?.[1] ?? null) : null; +} + +// Synthesize the delivery bodies eventsFromDelivery reads — SAME raw slice shapes +// as scripts/backfill-events.mjs (test/fixtures/*.json). PR list items carry no +// `merged` boolean (that's single-PR-fetch only), so derive it from merged_at. +function prClosedDelivery(pr: GhPrListItem) { + return { + action: "closed", + number: pr.number, + pull_request: { + number: pr.number, + title: pr.title, + body: pr.body, + html_url: pr.html_url, + merged: pr.merged_at != null, + merged_at: pr.merged_at, + closed_at: pr.closed_at, + user: { login: pr.user.login }, + milestone: pr.milestone + ? { number: pr.milestone.number, open_issues: pr.milestone.open_issues, closed_issues: pr.milestone.closed_issues } + : null, + }, + }; +} + +function issueDelivery(issue: GhIssueListItem) { + const assignee = issue.assignees?.[0] ?? issue.assignee ?? null; + const action = assignee ? "assigned" : "opened"; + return { + action, + ...(assignee ? { assignee: { login: assignee.login } } : {}), + issue: { + number: issue.number, + title: issue.title, + html_url: issue.html_url, + state: issue.state, + updated_at: issue.updated_at, + user: { login: issue.user.login }, + assignees: (issue.assignees ?? []).map((a) => ({ login: a.login })), + labels: issue.labels ?? [], + milestone: issue.milestone + ? { number: issue.milestone.number, open_issues: issue.milestone.open_issues, closed_issues: issue.milestone.closed_issues } + : null, + }, + }; +} + +export async function runBackfill( + env: Env, + principalLogin: string, + opts?: { fetchImpl?: typeof fetch; summarizer?: Summarizer | null; now?: string } +): Promise { + const token = env.GITHUB_SERVICE_TOKEN; + const repo = env.GITHUB_REPO; + if (!token || !repo) { + return { ok: false, error: "service token or repo not configured", captured: 0, unchanged: 0, prs: 0, issues: 0 }; + } + + const doFetch = opts?.fetchImpl ?? fetch; + const summarizer = opts?.summarizer ?? (env.AI ? workersAiSummarizer(env.AI) : null); + const headers = { + authorization: `Bearer ${token}`, + accept: GH_API, + "user-agent": USER_AGENT, + "x-github-api-version": "2022-11-28", + }; + const cutoffMs = new Date(opts?.now ?? nowIso()).getTime() - DAYS_BACK * 24 * 60 * 60 * 1000; + + // (a) Closed PRs updated in the last 14 days. Sorted updated-desc, so we stop + // paginating at the first item whose updated_at predates the cutoff. + const prList: GhPrListItem[] = []; + { + let url: string | null = `https://api.github.com/repos/${repo}/pulls?state=closed&sort=updated&direction=desc&per_page=100`; + let done = false; + while (url && !done) { + const res: Response = await doFetch(url, { headers }); + if (!res.ok) break; + const page = (await res.json()) as GhPrListItem[]; + for (const pr of page) { + if (pr.updated_at && new Date(pr.updated_at).getTime() < cutoffMs) { + done = true; + break; + } + prList.push(pr); + } + url = done ? null : nextLink(res); + } + } + + // (b) All open issues, paginated. The issues endpoint also returns PRs — those + // carry a `pull_request` field and are not our surface, so skip them. + const issueList: GhIssueListItem[] = []; + { + let url: string | null = `https://api.github.com/repos/${repo}/issues?state=open&per_page=100`; + while (url) { + const res: Response = await doFetch(url, { headers }); + if (!res.ok) break; + const page = (await res.json()) as GhIssueListItem[]; + for (const issue of page) { + if (issue.pull_request) continue; + issueList.push(issue); + } + url = nextLink(res); + } + } + + let captured = 0; + let unchanged = 0; + + for (const pr of prList) { + const payload = prClosedDelivery(pr); + for (const base of eventsFromDelivery("pull_request", payload)) { + const ev = { ...base, provenance: "backfill" as const }; + const res = await ingestEvent(env.DB, ev, principalLogin); + if (res.outcome === "written") { + captured++; + // Mirror handleGithubWebhook's summary seam: parse THIS PR's own raw and + // store a capture-time summary (storePrSummary never throws). + const parsed = JSON.parse(ev.raw) as { pr: { number: number; title: string; body: string | null } }; + await storePrSummary(env.DB, summarizer, { + semantic_key: ev.semantic_key, + pr_number: parsed.pr.number, + title: parsed.pr.title, + body: parsed.pr.body ?? "", + }); + } else { + unchanged++; + } + } + } + + for (const issue of issueList) { + const payload = issueDelivery(issue); + for (const base of eventsFromDelivery("issues", payload)) { + const ev = { ...base, provenance: "backfill" as const }; + const res = await ingestEvent(env.DB, ev, principalLogin); + if (res.outcome === "written") { + captured++; + // Mirror handleGithubWebhook's progress seam for newly-written issues. + await applyEventProgress(env.DB, payload); + } else { + unchanged++; + } + } + } + + return { ok: true, captured, unchanged, prs: prList.length, issues: issueList.length }; +} diff --git a/test/admin-route.test.ts b/test/admin-route.test.ts new file mode 100644 index 0000000..92b6238 --- /dev/null +++ b/test/admin-route.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from "vitest"; +import { env } from "cloudflare:test"; +import { app } from "../src/routes"; +import { createSession } from "../src/auth/session"; +import { hmacSeal } from "../src/auth/crypto"; + +// Seal a session cookie for `login` (mirrors test/dashboard-route.test.ts:9-15). +async function cookieFor(login: string): Promise { + await env.DB.prepare( + `INSERT OR IGNORE INTO users (github_login, name, created_at) VALUES (?, ?, ?)` + ).bind(login, login, "2026-01-01T00:00:00Z").run(); + const { id } = await createSession(env.DB, login); + return `session=${await hmacSeal(id, "test-cookie-secret")}`; +} + +describe("POST /admin/backfill (session- + admin-gated)", () => { + it("401s without a session", async () => { + const res = await app.request("/admin/backfill", { method: "POST" }, env); + expect(res.status).toBe(401); + }); + + it("403s for a non-admin principal", async () => { + const res = await app.request( + "/admin/backfill", + { method: "POST", headers: { cookie: await cookieFor("not-admin") } }, + env + ); + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ error: "admin only" }); + }); + + it("passes the admin gate and 503s when the service token is unset (proves wiring, no network)", async () => { + // ADMIN_LOGINS binds "admin-user" in vitest.config.ts, so this login clears + // isAdmin. GITHUB_SERVICE_TOKEN is a secret never set in tests, so runBackfill + // returns ok:false BEFORE any GitHub fetch → 503 with the config error. + const res = await app.request( + "/admin/backfill", + { method: "POST", headers: { cookie: await cookieFor("admin-user") } }, + env + ); + expect(res.status).toBe(503); + expect(await res.json()).toEqual({ error: "service token or repo not configured" }); + }); +}); diff --git a/test/backfill.test.ts b/test/backfill.test.ts new file mode 100644 index 0000000..17294bf --- /dev/null +++ b/test/backfill.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect } from "vitest"; +import { env } from "cloudflare:test"; +import { all, first } from "../src/db"; +import { runBackfill } from "../src/tools/backfill"; +import type { Env } from "../src/env"; +import type { Summarizer } from "../src/tools/summarize"; +import type { EventRow, PrSummaryRow } from "@shared/rows"; + +// Fixed "now" so the 14-day window is deterministic. threeDaysAgo is inside the +// window; twentyDaysAgo is outside it (must be excluded — and, being sorted +// updated-desc, must also stop pagination). +const NOW = "2026-07-01T00:00:00Z"; +const threeDaysAgo = "2026-06-28T00:00:00Z"; +const twentyDaysAgo = "2026-06-11T00:00:00Z"; + +// A Response-level fetch stub (the pool exports no fetch mock) — mirrors +// test/roadmap.test.ts / test/progress.test.ts. Routes by path substring: the +// pulls list vs the issues list. +function stubFetch(prs: unknown[], issues: unknown[]): typeof fetch { + return (async (url: string | URL | Request) => { + const u = String(url); + const body = u.includes("/pulls") ? prs : u.includes("/issues") ? issues : []; + return new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } }); + }) as unknown as typeof fetch; +} + +// Deterministic summarizer stub — never touches Workers AI. +const summarizer: Summarizer = { model: "test-model", summarize: async () => "AI summary" }; + +function envWith(overrides: Partial = {}): Env { + return { ...(env as unknown as Env), GITHUB_SERVICE_TOKEN: "svc-token", GITHUB_REPO: "o/r", ...overrides }; +} + +const mergedPr = { + number: 10, + title: "Add feature", + body: "This PR adds a feature.", + html_url: "https://github.com/o/r/pull/10", + merged_at: threeDaysAgo, // merged → derived merged:true from merged_at != null + closed_at: threeDaysAgo, + updated_at: threeDaysAgo, + user: { login: "octocat" }, + milestone: null, +}; +const oldPr = { + number: 5, + title: "Old PR", + body: "old", + html_url: "https://github.com/o/r/pull/5", + merged_at: twentyDaysAgo, + closed_at: twentyDaysAgo, + updated_at: twentyDaysAgo, // predates the cutoff → excluded (and stops pagination) + user: { login: "octocat" }, + milestone: null, +}; +const openIssue = { + number: 20, + title: "Fix bug", + html_url: "https://github.com/o/r/issues/20", + state: "open", + updated_at: threeDaysAgo, + user: { login: "octocat" }, + assignees: [{ login: "octocat" }], // has an assignee → "assigned" + labels: ["bug"], + milestone: null, +}; +const prAsIssue = { + number: 21, + title: "A PR the issues endpoint also returned", + html_url: "https://github.com/o/r/pull/21", + state: "open", + updated_at: threeDaysAgo, + user: { login: "octocat" }, + pull_request: { url: "https://api.github.com/repos/o/r/pulls/21" }, // → skipped + assignees: [], + labels: [], + milestone: null, +}; + +describe("runBackfill", () => { + it("captures in-window closed PRs + open issues as backfill events written by the admin principal", async () => { + const res = await runBackfill(envWith(), "admin-user", { + fetchImpl: stubFetch([mergedPr, oldPr], [openIssue, prAsIssue]), + summarizer, + now: NOW, + }); + + expect(res.ok).toBe(true); + expect(res.prs).toBe(1); // oldPr excluded by the 14-day window + expect(res.issues).toBe(1); // prAsIssue excluded (pull_request present) + expect(res.captured).toBe(2); + expect(res.unchanged).toBe(0); + + const events = await all(env.DB, `SELECT * FROM events ORDER BY ref_number`); + expect(events).toHaveLength(2); + for (const ev of events) { + expect(ev.provenance).toBe("backfill"); // provenance post-mapped from "webhook" + expect(ev.recorded_by).toBe("admin-user"); // writer is the ADMIN principal, not "github-webhook" + } + // The merged PR was captured as pr_merged (merged_at != null). + const pr = events.find((e) => e.ref_number === 10)!; + expect(pr.event_type).toBe("pr_merged"); + expect(pr.subject_login).toBe("octocat"); + + // The PR summary projection ran for the newly-written PR event. + const summary = await first(env.DB, `SELECT * FROM pr_summaries WHERE pr_number = ?`, 10); + expect(summary).toBeTruthy(); + expect(summary?.summary).toBe("AI summary"); + }); + + it("is idempotent — a second run over the same GitHub state writes nothing new", async () => { + const fetchImpl = stubFetch([mergedPr], [openIssue]); + const first = await runBackfill(envWith(), "admin-user", { fetchImpl, summarizer, now: NOW }); + expect(first.captured).toBe(2); + + const second = await runBackfill(envWith(), "admin-user", { fetchImpl, summarizer, now: NOW }); + expect(second.ok).toBe(true); + expect(second.captured).toBe(0); + expect(second.unchanged).toBe(2); + expect(await all(env.DB, `SELECT * FROM events`)).toHaveLength(2); // INSERT OR IGNORE on semantic_key + }); + + it("returns {ok:false} (no throw, no writes) when the service token is missing", async () => { + const res = await runBackfill(envWith({ GITHUB_SERVICE_TOKEN: undefined }), "admin-user", { + fetchImpl: stubFetch([mergedPr], [openIssue]), + summarizer, + now: NOW, + }); + expect(res.ok).toBe(false); + expect(res.error).toContain("service token or repo"); + expect(res).toMatchObject({ captured: 0, unchanged: 0, prs: 0, issues: 0 }); + expect(await all(env.DB, `SELECT * FROM events`)).toHaveLength(0); + }); +}); diff --git a/test/env.d.ts b/test/env.d.ts index e097c21..c070f80 100644 --- a/test/env.d.ts +++ b/test/env.d.ts @@ -16,6 +16,7 @@ declare global { GITHUB_WEBHOOK_SECRET?: string; GITHUB_REPO?: string; GITHUB_SERVICE_TOKEN?: string; + ADMIN_LOGINS?: string; } } } diff --git a/test/mcp.plan.test.ts b/test/mcp.plan.test.ts index c2f6c67..170b5fb 100644 --- a/test/mcp.plan.test.ts +++ b/test/mcp.plan.test.ts @@ -6,32 +6,39 @@ import { buildCanopyMcpServer } from "../src/mcp"; import { all, first } from "../src/db"; import type { MilestoneRow, PlanRow } from "@shared/rows"; -const AUTHOR = "admin-agent"; +// ADMIN_LOGINS binds "admin-user" in vitest.config.ts — this login clears isAdmin(). +const AUTHOR = "admin-user"; // Drive the ACTUAL registered MCP `update_plan` tool through an in-memory MCP // client/server pair — the same closure production runs (mirrors // test/mcp.append_feed.test.ts's callTool helper). -async function callTool(name: string, args: Record): Promise<{ text: string; isError?: boolean }> { - const server = buildCanopyMcpServer(env as unknown as import("../src/env").Env, { login: AUTHOR }); +async function withClient(login: string, fn: (client: Client) => Promise): Promise { + const server = buildCanopyMcpServer(env as unknown as import("../src/env").Env, { login }); const client = new Client({ name: "test", version: "1.0.0" }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); await server.connect(serverTransport); await client.connect(clientTransport); try { + return await fn(client); + } finally { + await client.close(); + await server.close(); + } +} + +async function callTool(login: string, name: string, args: Record): Promise<{ text: string; isError?: boolean }> { + return withClient(login, async (client) => { const res = (await client.callTool({ name, arguments: args })) as { content: Array<{ type: string; text: string }>; isError?: boolean; }; return { text: res.content[0].text, isError: res.isError }; - } finally { - await client.close(); - await server.close(); - } + }); } describe("registered MCP update_plan tool", () => { it("writes as the bearer principal — author stamped from the principal, never the payload", async () => { - const res = await callTool("update_plan", { + const res = await callTool(AUTHOR, "update_plan", { narrative: "shipped via MCP", milestones: [{ title: "MCP Milestone", target_date: "2026-08-01", status: "upcoming" }], }); @@ -51,10 +58,35 @@ describe("registered MCP update_plan tool", () => { }); it("milestones default to [] when omitted", async () => { - const res = await callTool("update_plan", { narrative: "narrative only" }); + const res = await callTool(AUTHOR, "update_plan", { narrative: "narrative only" }); expect(res.isError).toBeFalsy(); const body = JSON.parse(res.text); expect(body.version).toBe(1); expect(body.milestones).toEqual([]); }); }); + +describe("update_plan is admin-only — non-admin principals don't even get the tool", () => { + it("a non-admin's tools/list omits update_plan but still includes get_roadmap", async () => { + const { tools } = await withClient("agent", (client) => client.listTools()); + const names = tools.map((t) => t.name); + expect(names).not.toContain("update_plan"); + expect(names).toContain("get_roadmap"); + }); + + it("an admin's tools/list includes update_plan", async () => { + const { tools } = await withClient(AUTHOR, (client) => client.listTools()); + const names = tools.map((t) => t.name); + expect(names).toContain("update_plan"); + }); + + it("a non-admin calling update_plan gets a tool-not-found MCP error", async () => { + const res = await callTool("agent", "update_plan", { narrative: "should not land" }); + expect(res.isError).toBeTruthy(); + expect(res.text.toLowerCase()).toContain("not found"); + + // Nothing was written — the seeded plan row is untouched. + const plan = await first(env.DB, `SELECT * FROM plan WHERE id = 1`); + expect(plan?.narrative).not.toBe("should not land"); + }); +}); diff --git a/test/render.mywork.test.ts b/test/render.mywork.test.ts index c0ec34c..59b2cec 100644 --- a/test/render.mywork.test.ts +++ b/test/render.mywork.test.ts @@ -140,13 +140,13 @@ describe("todoCard", () => { // ── full render() — My Work screen composition ────────────────────────────── describe("render() — My Work screen", () => { - function stateWithDashboard(data: DashboardData): ReturnType { + function stateWithDashboard(data: DashboardData, admin = false): ReturnType { const s = initialState(); return { ...s, view: "app", screen: "mywork", - me: { login: "alice", name: "Alice", avatar_url: null, org: "SaplingLearn" }, + me: { login: "alice", name: "Alice", avatar_url: null, org: "SaplingLearn", admin }, mywork: { status: "ok", data }, }; } @@ -203,4 +203,18 @@ describe("render() — My Work screen", () => { expect(html).toContain("Previous activity"); expect(html).toContain("To-do"); }); + + // ── admin-only Sync GitHub button (server-side backfill trigger) ──────────── + it("renders the Sync GitHub backfill button for an admin me", () => { + const data: DashboardData = { person: "alice", previousActivity: [], todo: [], degraded: false }; + const html = render(stateWithDashboard(data, true)); + expect(html).toContain('data-act="adminBackfill"'); + expect(html).toContain("Sync GitHub"); + }); + + it("does NOT render the Sync GitHub button for a non-admin me", () => { + const data: DashboardData = { person: "alice", previousActivity: [], todo: [], degraded: false }; + const html = render(stateWithDashboard(data, false)); + expect(html).not.toContain('data-act="adminBackfill"'); + }); }); diff --git a/test/render.roadmap.test.ts b/test/render.roadmap.test.ts index 23babe2..f669d5a 100644 --- a/test/render.roadmap.test.ts +++ b/test/render.roadmap.test.ts @@ -72,7 +72,7 @@ function stateWithPlan(plan: PlanView, tab: "narrative" | "timeline"): ReturnTyp view: "app", screen: "roadmap", roadmapTab: tab, - me: { login: "alice", name: "Alice", avatar_url: null, org: "SaplingLearn" }, + me: { login: "alice", name: "Alice", avatar_url: null, org: "SaplingLearn", admin: false }, roadmap: { status: "ok", data: plan }, }; } @@ -190,7 +190,7 @@ describe("search results — milestone hits navigate via goRoadmap", () => { ...s, view: "app", screen: "search", - me: { login: "alice", name: "Alice", avatar_url: null, org: "SaplingLearn" }, + me: { login: "alice", name: "Alice", avatar_url: null, org: "SaplingLearn", admin: false }, searchResults: { status: "ok", data: { @@ -215,7 +215,7 @@ describe("search results — milestone hits navigate via goRoadmap", () => { ...s, view: "app", screen: "search", - me: { login: "alice", name: "Alice", avatar_url: null, org: "SaplingLearn" }, + me: { login: "alice", name: "Alice", avatar_url: null, org: "SaplingLearn", admin: false }, searchResults: { status: "ok", data: { diff --git a/test/render.triage.test.ts b/test/render.triage.test.ts index 5f337bf..f4521b4 100644 --- a/test/render.triage.test.ts +++ b/test/render.triage.test.ts @@ -392,7 +392,7 @@ describe("XSS: proposal slug in triage list and detail is attribute-escaped", () view: "app", screen: "triage", triageQueue: "proposals", - me: { login: "reviewer", name: null, avatar_url: null, org: "SaplingLearn" }, + me: { login: "reviewer", name: null, avatar_url: null, org: "SaplingLearn", admin: false }, proposals: { status: "ok", data: [proposal] }, selProposal: key, }; diff --git a/vitest.config.ts b/vitest.config.ts index d96e286..b08db02 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -31,6 +31,7 @@ export default defineConfig({ GITHUB_CLIENT_ID: "test-client-id", GITHUB_CLIENT_SECRET: "test-client-secret", GITHUB_WEBHOOK_SECRET: "test-webhook-secret", + ADMIN_LOGINS: "admin-user", // the admin allowlist the admin-gated route + isAdmin() test against DEV_LOGIN: "", // override .dev.vars: tests exercise REAL auth, never the dev bypass }, }, diff --git a/web/src/api.ts b/web/src/api.ts index aea01a8..e12f064 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -119,11 +119,17 @@ export function listMilestoneProposals(): Promise { return getJson<{ proposals: MilestoneProposalRow[] }>("/milestone-proposals").then((r) => r.proposals); } -export interface Me { login: string; name: string | null; avatar_url: string | null; org: string; } +export interface Me { login: string; name: string | null; avatar_url: string | null; org: string; admin: boolean; } export function getMe(): Promise { return getJson("/auth/me"); } +// ADMIN action: trigger the server-side GitHub backfill (admin-only route). The +// worker holds the service token and fetches GitHub directly — no webhook secret. +export function adminBackfill(): Promise<{ ok: boolean; captured: number; unchanged: number; prs: number; issues: number }> { + return postJson("/admin/backfill", {}); +} + export function getMyDashboard(): Promise { return getJson("/me/dashboard"); } diff --git a/web/src/main.ts b/web/src/main.ts index 9984b7e..ff5f655 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -10,7 +10,7 @@ import { listStagedProposals, listAdrs, listNeedsTriage, listMilestoneProposals, promoteDoc, ratifyAdr, completeMilestone, promoteMilestoneProposal, rejectDoc, rejectAdr, rejectMilestoneProposal, discardTriage, assignTriage, - getMe, logout, mintMcpToken, + getMe, logout, mintMcpToken, adminBackfill, Unauthorized, NotFound, ApiError, } from "./api"; @@ -477,6 +477,18 @@ function dispatch(act: string, arg: string | null, value: string | null): void { }); return; } + // ADMIN action (My Work): trigger the server-side GitHub backfill, then + // refresh My Work so newly-captured PRs/issues surface in the two lists. + case "adminBackfill": { + flash("Syncing GitHub…"); + adminBackfill() + .then((r) => { flash(`Synced: ${r.captured} captured, ${r.unchanged} unchanged`); loadMyWork(); }) + .catch((e) => { + if (e instanceof Unauthorized) { state.view = "auth"; state.authStep = "login"; rerender(); return; } + flash(e instanceof ApiError ? e.message : "Sync failed"); + }); + return; + } // ── Phase 3 triage write-back (cookie-authed) ─────────────────────────── // Dismiss = reject. Shared by the Proposals queue (arg "slug@version" → reject // the staged doc version), the Decisions queue (arg id → reject the ADR), and diff --git a/web/src/render.ts b/web/src/render.ts index bc3405b..e8e0872 100644 --- a/web/src/render.ts +++ b/web/src/render.ts @@ -336,6 +336,14 @@ function header(s: AppState): string { ` : ""; + // ADMIN-only, My Work screen: trigger the server-side GitHub backfill. Rendered + // only when /auth/me returned admin:true (outline button, promote-class action). + const myworkControls = s.screen === "mywork" && s.me?.admin + ? `` : ""; + const themeBtn = `