Public: anyone can read this; the crew can change it.
Proposal tokens on the repository settings page: one line each with Revoke, and a mint form that shows the secret once #2215
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2147-proposal-tokens-on-the-repository-settin → main 3 commits fork 4 threads · 4 unresolved
README.md +3
| @@ -108,6 +108,9 @@ Minting, listing and revoking go through one action | ||
| 108 | 108 | (`src/actions/proposal_tokens.ts`) at every door, and only a writer of the |
| 109 | 109 | repository may; anyone else is told "no such resource here, or it is not |
| 110 | 110 | visible to you", as the API says it. The secret is shown once, on minting. |
| 111 | The repository's settings page is one more door: a writer sees their proposal | |
| 112 | tokens one to a line (child, origin, expires, last used) with Revoke, and a | |
| 113 | form that mints one and shows its secret once; others see no section. | |
| 111 | 114 | |
| 112 | 115 | - **MCP**: the `proposal_tokens` tool with `repo` and a `verb`: `mint` |
| 113 | 116 | (`child`, `origin`, optionally `childRepo`), `list` (the ones you minted for |
src/http/server.ts +7
| @@ -421,6 +421,7 @@ import { notFoundPage } from "../web/chrome.ts"; | ||
| 421 | 421 | import { targetForShortCode } from "../origins/oauth.ts"; |
| 422 | 422 | import { apiRoutes } from "./api.ts"; |
| 423 | 423 | import { inNamespace } from "../core/proposal_token.ts"; |
| 424 | import { proposalTokenMint, proposalTokenRevoke } from "../web/pages/proposal-tokens.ts"; | |
| 424 | 425 | import { proposalGate } from "./proposal_gate.ts"; |
| 425 | 426 | import { feedRoutes } from "./feed.ts"; |
| 426 | 427 | import type { Database } from "bun:sqlite"; |
| @@ -1230,6 +1231,12 @@ export function buildRouter(): Router<Handler> { | ||
| 1230 | 1231 | router.add("POST", "/:owner/:name/settings", settingsUpdate); |
| 1231 | 1232 | router.add("POST", "/:owner/:name/settings/wave", waveCut); |
| 1232 | 1233 | router.add("POST", "/:owner/:name/settings/archive", archiveSubmit); |
| 1234 | router.add("POST", "/:owner/:name/settings/proposal-tokens", proposalTokenMint); | |
| 1235 | router.add( | |
| 1236 | "POST", | |
| 1237 | "/:owner/:name/settings/proposal-tokens/:id/delete", | |
| 1238 | proposalTokenRevoke, | |
| 1239 | ); | |
| 1233 | 1240 | router.add("GET", "/:owner/:name/settings/app", appSettingsPage); |
| 1234 | 1241 | router.add("POST", "/:owner/:name/settings/app", appSettingsUpdate); |
| 1235 | 1242 | router.add("POST", "/:owner/:name/settings/app/disable", appDisable); |
src/web/pages/proposal-tokens.ts added +108
| @@ -0,0 +1,108 @@ | ||
| 1 | /** | |
| 2 | * Proposal tokens on the repository settings page: one line each — child, | |
| 3 | * origin, expires, last used — with Revoke, and a form that mints one and | |
| 4 | * shows its secret once, on the flash. Writers only: anyone else sees no | |
| 5 | * section and their POSTs are 403. | |
| 6 | */ | |
| 7 | ||
| 8 | import { listProposals, mintProposal, revokeProposal } from "../../actions/proposal_tokens.ts"; | |
| 9 | import { canWrite, type Repo } from "../../core/repos.ts"; | |
| 10 | import type { Ctx } from "../../http/context.ts"; | |
| 11 | import { loadRepo, repoBase, repoNotFound } from "../chrome.ts"; | |
| 12 | import { field, readForm } from "../form.ts"; | |
| 13 | import { html, type Html } from "../html.ts"; | |
| 14 | import { flashTo } from "../layout.ts"; | |
| 15 | import { btn, card, confirmAction, field as formField, icon, time } from "../ui.ts"; | |
| 16 | ||
| 17 | /** The section, or null for someone who cannot write here. */ | |
| 18 | export function proposalTokensSection(ctx: Ctx, repo: Repo): Html | null { | |
| 19 | if (!canWrite(ctx.app.db, repo, ctx.auth.user)) return null; | |
| 20 | const base = repoBase(repo); | |
| 21 | const listed = listProposals(ctx.app, ctx.auth.user, repo); | |
| 22 | const tokens = listed.ok ? listed.value : []; | |
| 23 | return card( | |
| 24 | html`<p class="muted"> | |
| 25 | A proposal token lets one named child forge push | |
| 26 | <code>child/<name>/…</code> branches here and open pulls from | |
| 27 | them — nothing else. It lives 30 days. | |
| 28 | </p> | |
| 29 | ${tokens.length === 0 | |
| 30 | ? html`<p class="muted">No proposal tokens.</p>` | |
| 31 | : html`<div class="table-scroll"> | |
| 32 | <table class="table"> | |
| 33 | <thead> | |
| 34 | <tr><th>Child</th><th>Origin</th><th>Expires</th><th>Last used</th><th></th></tr> | |
| 35 | </thead> | |
| 36 | <tbody> | |
| 37 | ${tokens.map( | |
| 38 | (t) => | |
| 39 | html`<tr> | |
| 40 | <td><strong>${t.child}</strong></td> | |
| 41 | <td class="muted mono">${t.origin}</td> | |
| 42 | <td class="muted">${t.expiresAt ? time(t.expiresAt) : "never"}</td> | |
| 43 | <td class="muted">${t.lastUsedAt ? time(t.lastUsedAt) : "never"}</td> | |
| 44 | <td> | |
| 45 | ${confirmAction({ | |
| 46 | action: `${base}/settings/proposal-tokens/${t.id}/delete`, | |
| 47 | verb: "Revoke", | |
| 48 | size: "sm", | |
| 49 | })} | |
| 50 | </td> | |
| 51 | </tr>`, | |
| 52 | )} | |
| 53 | </tbody> | |
| 54 | </table> | |
| 55 | </div>`} | |
| 56 | <form method="post" action="${base}/settings/proposal-tokens"> | |
| 57 | ${formField({ label: "Child", name: "child", mono: true, required: true, hint: "Lowercase words and hyphens." })} | |
| 58 | ${formField({ label: "Origin", name: "origin", mono: true, required: true, placeholder: "https://child.example.com/", hint: "Where the child lives." })} | |
| 59 | <div class="form-actions">${btn("Mint token", { variant: "primary" })}</div> | |
| 60 | </form>`, | |
| 61 | { head: html`${icon("key")}<h2>Proposal tokens</h2>` }, | |
| 62 | ); | |
| 63 | } | |
| 64 | ||
| 65 | /** The repository for a writer's POST, or the response that refuses it. */ | |
| 66 | async function writerForm(ctx: Ctx) { | |
| 67 | const repo = loadRepo(ctx); | |
| 68 | if (!repo) return repoNotFound(ctx); | |
| 69 | const form = await readForm(ctx, "rw"); | |
| 70 | if (form instanceof Response) return form; | |
| 71 | if (!canWrite(ctx.app.db, repo, ctx.auth.user)) | |
| 72 | return new Response("forbidden", { status: 403 }); | |
| 73 | return { repo, form }; | |
| 74 | } | |
| 75 | ||
| 76 | // POST /:owner/:name/settings/proposal-tokens | |
| 77 | export async function proposalTokenMint(ctx: Ctx): Promise<Response> { | |
| 78 | const gate = await writerForm(ctx); | |
| 79 | if (gate instanceof Response) return gate; | |
| 80 | const { repo, form } = gate; | |
| 81 | // Never minted by another token: a credential cannot grow credentials. | |
| 82 | if (ctx.auth.via !== "session" && ctx.auth.via !== "password") | |
| 83 | return new Response("forbidden", { status: 403 }); | |
| 84 | const minted = mintProposal(ctx.app, ctx.auth.user, repo, { | |
| 85 | child: field(form, "child"), | |
| 86 | origin: field(form, "origin"), | |
| 87 | }); | |
| 88 | const back = `${repoBase(repo)}/settings`; | |
| 89 | if (!minted.ok) | |
| 90 | return minted.error.code === "invalid_child" || minted.error.code === "invalid_origin" | |
| 91 | ? flashTo(ctx, back, { tone: "danger", message: minted.error.message }) | |
| 92 | : new Response("forbidden", { status: 403 }); | |
| 93 | // Post-redirect-get: the secret rides a one-shot flash, never the page. | |
| 94 | return flashTo(ctx, back, { | |
outdated
Minor: the |
||
| 95 | message: "Proposal token minted — copy it now, it is never shown again.", | |
| 96 | code: minted.value.token, | |
| 97 | }); | |
| 98 | } | |
| 99 | ||
| 100 | // POST /:owner/:name/settings/proposal-tokens/:id/delete | |
| 101 | export async function proposalTokenRevoke(ctx: Ctx): Promise<Response> { | |
| 102 | const gate = await writerForm(ctx); | |
| 103 | if (gate instanceof Response) return gate; | |
| 104 | const revoked = revokeProposal(ctx.app, ctx.auth.user, gate.repo, ctx.params.id ?? ""); | |
| 105 | return flashTo(ctx, `${repoBase(gate.repo)}/settings`, revoked.ok | |
| 106 | ? { message: "Proposal token revoked." } | |
| 107 | : { tone: "danger", message: "No such proposal token." }); | |
| 108 | } |
src/web/pages/search.ts +9
| @@ -480,6 +480,15 @@ const DESTRUCTIVE: { | ||
| 480 | 480 | phrase: "", |
| 481 | 481 | }), |
| 482 | 482 | }, |
| 483 | { | |
| 484 | pattern: /^\/([^/]+)\/([^/]+)\/settings\/proposal-tokens\/([^/]+)\/delete$/, | |
| 485 | describe: (m) => ({ | |
| 486 | verb: "Revoke", | |
| 487 | title: "Revoke this proposal token?", | |
| 488 | detail: `The child can no longer push child/<name>/… branches or open pulls on ${m[1]}/${m[2]}. Pulls it already opened stay. A new token has to be minted to let it propose again.`, | |
| 489 | phrase: "", | |
| 490 | }), | |
| 491 | }, | |
| 483 | 492 | { |
| 484 | 493 | pattern: /^\/([^/]+)\/([^/]+)\/settings\/prompts\/([^/]+)\/delete$/, |
| 485 | 494 | describe: (m) => ({ |
src/web/pages/settings.ts +9 −3
| @@ -102,6 +102,7 @@ import { | ||
| 102 | 102 | timeAgo, |
| 103 | 103 | } from "../ui.ts"; |
| 104 | 104 | import { cutWaveFor } from "../../actions/cut_wave.ts"; |
| 105 | import { proposalTokensSection } from "./proposal-tokens.ts"; | |
| 105 | 106 | import { secretsSection } from "./secrets.ts"; |
| 106 | 107 | import { saveGrants } from "./subscriptions.ts"; |
| 107 | 108 | |
| @@ -212,15 +213,17 @@ export async function settingsPage(ctx: Ctx): Promise<Response> { | ||
| 212 | 213 | if (!readable) return repoNotFound(ctx); |
| 213 | 214 | const lead = settingsLead(ctx, readable); |
| 214 | 215 | const repo = loadAdminRepo(ctx); |
| 215 | if (!repo) | |
| 216 | if (!repo) { | |
| 217 | const proposals = proposalTokensSection(ctx, readable); | |
| 216 | 218 | return page({ |
| 217 | 219 | ctx, |
| 218 | 220 | title: pageTitle("settings", readable, true), |
| 219 | 221 | crumbs: crumbsFor(readable), |
| 220 | 222 | palette: repoPalette(ctx, readable), |
| 221 | 223 | body: html`${repoHeader(ctx, readable, "settings", { compact: true })} |
| 222 | ${lead}`, | |
| 224 | ${lead}${proposals ? html`<div class="mt">${proposals}</div>` : null}`, | |
| 223 | 225 | }); |
| 226 | } | |
| 224 | 227 | const base = repoBase(repo); |
| 225 | 228 | const dir = repoDir(ctx.app.reposRoot, repo.ownerName, repo.name); |
| 226 | 229 | const refs = await listRefs(dir, "refs/heads/"); |
| @@ -322,6 +325,7 @@ export async function settingsPage(ctx: Ctx): Promise<Response> { | ||
| 322 | 325 | </form>`, |
| 323 | 326 | { head: html`${icon("rocket")}<h2>Cut a wave</h2>` }, |
| 324 | 327 | ); |
| 328 | const proposals = proposalTokensSection(ctx, repo); | |
| 325 | 329 | const archive = card( |
| 326 | 330 | html`<form method="post" action="${base}/settings/archive"> |
| 327 | 331 | <input type="hidden" name="archived" value="${repo.archivedAt === null ? "1" : "0"}" /> |
| @@ -343,7 +347,9 @@ export async function settingsPage(ctx: Ctx): Promise<Response> { | ||
| 343 | 347 | repo, |
| 344 | 348 | "general", |
| 345 | 349 | "Settings", |
| 346 | html`${lead}<div class="mt">${body}</div><div class="mt">${wave}</div><div class="mt">${archive}</div>${secrets | |
| 350 | html`${lead}<div class="mt">${body}</div><div class="mt">${wave}</div><div class="mt">${archive}</div>${proposals | |
| 351 | ? html`<div class="mt">${proposals}</div>` | |
| 352 | : null}${secrets | |
| 347 | 353 | ? html`<div class="mt">${secrets}</div>` |
| 348 | 354 | : null}`, |
| 349 | 355 | ); |
test/proposal-token-settings.test.ts added +163
| @@ -0,0 +1,163 @@ | ||
| 1 | /** | |
| 2 | * Proposal tokens on the repository settings page: mint (secret once), list, | |
| 3 | * revoke (after which a push with it is refused), and who may. | |
| 4 | */ | |
| 5 | ||
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 12 | import { quietLogs } from "./helpers/globals.ts"; | |
| 13 | ||
| 14 | quietLogs(); | |
| 15 | ||
| 16 | describe("proposal tokens on the settings page", () => { | |
| 17 | let dataDir: string; | |
| 18 | let app: App; | |
| 19 | let servers: Servers; | |
| 20 | let base: string; | |
| 21 | let owner = ""; | |
| 22 | let stranger = ""; | |
| 23 | ||
| 24 | const form = (fields: Record<string, string>) => new URLSearchParams(fields); | |
| 25 | ||
| 26 | async function signup(username: string): Promise<string> { | |
| 27 | const res = await fetch(`${base}/signup`, { | |
| 28 | method: "POST", | |
| 29 | body: form({ username, email: `${username}@f.dev`, password: "proposal-password" }), | |
| 30 | redirect: "manual", | |
| 31 | }); | |
| 32 | return (res.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 33 | } | |
| 34 | ||
| 35 | const post = (cookie: string, path: string, fields: Record<string, string> = {}, origin = base) => | |
| 36 | fetch(`${base}${path}`, { | |
| 37 | method: "POST", | |
| 38 | headers: { cookie, origin }, | |
| 39 | body: form(fields), | |
| 40 | redirect: "manual", | |
| 41 | }); | |
| 42 | ||
| 43 | const settings = (cookie: string) => | |
| 44 | fetch(`${base}/ada/lib/settings`, { headers: { cookie } }).then((r) => r.text()); | |
| 45 | ||
| 46 | const git = async (args: string[], cwd: string) => { | |
| 47 | const proc = Bun.spawn(["git", "-c", "credential.helper=", ...args], { | |
| 48 | cwd, | |
| 49 | env: { | |
| 50 | PATH: process.env.PATH ?? "", | |
| 51 | GIT_TERMINAL_PROMPT: "0", | |
| 52 | GIT_CONFIG_NOSYSTEM: "1", | |
| 53 | GIT_CONFIG_GLOBAL: "/dev/null", | |
| 54 | HOME: dataDir, | |
| 55 | }, | |
| 56 | stdout: "pipe", | |
| 57 | stderr: "pipe", | |
| 58 | }); | |
| 59 | await new Response(proc.stdout).text(); | |
| 60 | await new Response(proc.stderr).text(); | |
| 61 | return proc.exited; | |
| 62 | }; | |
| 63 | ||
| 64 | /** Mint through the form; the secret as the flash after the redirect carries it. */ | |
| 65 | async function mint(child: string): Promise<{ secret: string; page: string }> { | |
| 66 | const res = await post(owner, "/ada/lib/settings/proposal-tokens", { | |
| 67 | child, | |
| 68 | origin: "https://kid.example.com/", | |
| 69 | }); | |
| 70 | expect(res.status).toBe(303); | |
| 71 | const flash = (res.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 72 | const page = await fetch(`${base}${res.headers.get("location")}`, { | |
| 73 | headers: { cookie: `${owner}; ${flash}` }, | |
| 74 | }).then((r) => r.text()); | |
| 75 | const secret = /forge_[A-Za-z0-9_-]+/.exec(page)?.[0] ?? ""; | |
| 76 | return { secret, page }; | |
| 77 | } | |
| 78 | ||
| 79 | beforeAll(async () => { | |
| 80 | dataDir = await mkdtemp(join(tmpdir(), "forge-proposal-settings-")); | |
| 81 | app = createApp({ dataDir, port: 0 }); | |
| 82 | servers = await serve(app); | |
| 83 | base = servers.url; | |
| 84 | owner = await signup("ada"); | |
| 85 | stranger = await signup("bob"); | |
| 86 | await post(owner, "/new", { name: "lib", visibility: "public", description: "d" }); | |
| 87 | }); | |
| 88 | ||
| 89 | afterAll(async () => { | |
| 90 | servers.stop(); | |
| 91 | app.close(); | |
| 92 | await rm(dataDir, { recursive: true, force: true }); | |
| 93 | }); | |
| 94 | ||
| 95 | test("mint shows the secret once, lists the token, and revoke ends its push", async () => { | |
| 96 | const { secret, page } = await mint("kid"); | |
| 97 | expect(secret).not.toBe(""); | |
| 98 | expect(page).toContain("never shown again"); | |
| 99 | ||
| 100 | // Gone on the next view, but the line stays. | |
| 101 | const after = await settings(owner); | |
| 102 | expect(after).not.toContain(secret); | |
| 103 | expect(after).toContain("kid"); | |
| 104 | expect(after).toContain("https://kid.example.com/"); | |
| 105 | expect(after).toContain("Revoke"); | |
| 106 | ||
| 107 | const work = join(dataDir, "work"); | |
| 108 | await Bun.$`mkdir -p ${work}`.quiet(); | |
| 109 | await git(["init", "-q", "-b", "main"], work); | |
| 110 | await Bun.write(join(work, "a.md"), "a\n"); | |
| 111 | await git(["add", "."], work); | |
| 112 | await git(["-c", "[email protected]", "-c", "user.name=k", "commit", "-q", "-m", "a"], work); | |
| 113 | const url = `${base.replace("://", `://child:${secret}@`)}/ada/lib.git`; | |
| 114 | expect(await git(["push", url, "HEAD:refs/heads/child/kid/one"], work)).toBe(0); | |
| 115 | ||
| 116 | // Follow the button, as a browser would: the confirm page, then its form. | |
| 117 | const link = /href="(\/-\/confirm\?to=[^"]+)"/.exec(after)?.[1]; | |
| 118 | expect(link).toBeDefined(); | |
outdated
This POSTs to the delete route directly, so it passes even though the Revoke button 404s. Follow the button instead: GET |
||
| 119 | const confirm = await fetch(`${base}${link!.replaceAll("&", "&")}`, { headers: { cookie: owner } }); | |
| 120 | expect(confirm.status).toBe(200); | |
| 121 | const action = /<form method="post" action="([^"]*settings\/proposal-tokens[^"]+)"/.exec(await confirm.text())?.[1]; | |
| 122 | expect(action).toContain("/settings/proposal-tokens/"); | |
| 123 | expect((await post(owner, action!)).status).toBe(303); | |
| 124 | expect(await git(["push", url, "HEAD:refs/heads/child/kid/two"], work)).not.toBe(0); | |
| 125 | expect(await settings(owner)).not.toContain("https://kid.example.com/"); | |
outdated
Nothing asserts that |
||
| 126 | }); | |
| 127 | ||
| 128 | test("a bad child or origin mints nothing", async () => { | |
| 129 | const rows = (page: string) => page.match(/<tr>/g)?.length ?? 0; | |
| 130 | const before = rows(await settings(owner)); | |
| 131 | for (const fields of [ | |
| 132 | { child: "Bad Name", origin: "https://kid.example.com/" }, | |
| 133 | { child: "ok", origin: "http://plain.example.com/" }, | |
| 134 | ]) { | |
| 135 | const res = await post(owner, "/ada/lib/settings/proposal-tokens", fields); | |
| 136 | expect(res.status).toBe(303); | |
| 137 | const flash = (res.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 138 | const shown = await fetch(`${base}${res.headers.get("location")}`, { | |
| 139 | headers: { cookie: `${owner}; ${flash}` }, | |
| 140 | }).then((r) => r.text()); | |
| 141 | expect(shown).not.toContain("never shown again"); | |
| 142 | expect(shown).toMatch(/lowercase words|https address/); | |
| 143 | } | |
| 144 | expect(rows(await settings(owner))).toBe(before); | |
| 145 | }); | |
| 146 | ||
| 147 | test("a cross-origin POST is refused", async () => { | |
| 148 | const res = await post(owner, "/ada/lib/settings/proposal-tokens", { child: "x", origin: "https://x.example.com/" }, "https://evil.example"); | |
| 149 | expect(res.status).toBe(403); | |
| 150 | }); | |
| 151 | ||
| 152 | test("a non-writer sees no section and their POSTs are 403", async () => { | |
| 153 | expect(await settings(stranger)).not.toContain("Proposal tokens"); | |
| 154 | expect(await settings(owner)).toContain("Proposal tokens"); | |
| 155 | const mintRes = await post(stranger, "/ada/lib/settings/proposal-tokens", { child: "x", origin: "https://x.example.com/" }); | |
| 156 | expect(mintRes.status).toBe(403); | |
| 157 | const { secret } = await mint("keep"); | |
| 158 | expect(secret).not.toBe(""); | |
| 159 | const id = /proposal-tokens\/([^/"%]+)\/delete/.exec(decodeURIComponent(await settings(owner)))?.[1]; | |
| 160 | expect((await post(stranger, `/ada/lib/settings/proposal-tokens/${id}/delete`)).status).toBe(403); | |
| 161 | expect(await settings(owner)).toContain("keep"); | |
| 162 | }); | |
| 163 | }); |
High: this goes through
/-/confirm, which returns 404 for any target missing fromDESTRUCTIVEinsrc/web/pages/search.ts. Revoke is unreachable on the page. Add a confirm entry for/settings/proposal-tokens/:id/delete.