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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/2147-proposal-tokens-on-the-repository-settin → main 3 commits fork 4 threads · 4 unresolved

  • Merged. scout/2147-proposal-tokens-on-the-repository-settin was merged into main as fa216c6.

Review

Sign in to review this change.
6 files changed +299 −3
README.md +3
@@ -108,6 +108,9 @@ Minting, listing and revoking go through one action
108108 (`src/actions/proposal_tokens.ts`) at every door, and only a writer of the
109109 repository may; anyone else is told "no such resource here, or it is not
110110 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.
111114
112115 - **MCP**: the `proposal_tokens` tool with `repo` and a `verb`: `mint`
113116 (`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";
421421 import { targetForShortCode } from "../origins/oauth.ts";
422422 import { apiRoutes } from "./api.ts";
423423 import { inNamespace } from "../core/proposal_token.ts";
424 import { proposalTokenMint, proposalTokenRevoke } from "../web/pages/proposal-tokens.ts";
424425 import { proposalGate } from "./proposal_gate.ts";
425426 import { feedRoutes } from "./feed.ts";
426427 import type { Database } from "bun:sqlite";
@@ -1230,6 +1231,12 @@ export function buildRouter(): Router<Handler> {
12301231 router.add("POST", "/:owner/:name/settings", settingsUpdate);
12311232 router.add("POST", "/:owner/:name/settings/wave", waveCut);
12321233 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 );
12331240 router.add("GET", "/:owner/:name/settings/app", appSettingsPage);
12341241 router.add("POST", "/:owner/:name/settings/app", appSettingsUpdate);
12351242 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/&lt;name&gt;/…</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>`,
outdated

High: this goes through /-/confirm, which returns 404 for any target missing from DESTRUCTIVE in src/web/pages/search.ts. Revoke is unreachable on the page. Add a confirm entry for /settings/proposal-tokens/:id/delete.

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 as keyof typeof MINT_ERRORS cast hides not_found, which is part of ProposalTokenError but which mintForChild never returns. If mintProposalToken ever gains another error, this flash shows undefined. Give mintForChild a narrower error type ("forbidden" | "invalid_child" | "invalid_origin") so the lookup type-checks without the cast.

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: {
480480 phrase: "",
481481 }),
482482 },
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 },
483492 {
484493 pattern: /^\/([^/]+)\/([^/]+)\/settings\/prompts\/([^/]+)\/delete$/,
485494 describe: (m) => ({
src/web/pages/settings.ts +9 −3
@@ -102,6 +102,7 @@ import {
102102 timeAgo,
103103 } from "../ui.ts";
104104 import { cutWaveFor } from "../../actions/cut_wave.ts";
105 import { proposalTokensSection } from "./proposal-tokens.ts";
105106 import { secretsSection } from "./secrets.ts";
106107 import { saveGrants } from "./subscriptions.ts";
107108
@@ -212,15 +213,17 @@ export async function settingsPage(ctx: Ctx): Promise<Response> {
212213 if (!readable) return repoNotFound(ctx);
213214 const lead = settingsLead(ctx, readable);
214215 const repo = loadAdminRepo(ctx);
215 if (!repo)
216 if (!repo) {
217 const proposals = proposalTokensSection(ctx, readable);
216218 return page({
217219 ctx,
218220 title: pageTitle("settings", readable, true),
219221 crumbs: crumbsFor(readable),
220222 palette: repoPalette(ctx, readable),
221223 body: html`${repoHeader(ctx, readable, "settings", { compact: true })}
222 ${lead}`,
224 ${lead}${proposals ? html`<div class="mt">${proposals}</div>` : null}`,
223225 });
226 }
224227 const base = repoBase(repo);
225228 const dir = repoDir(ctx.app.reposRoot, repo.ownerName, repo.name);
226229 const refs = await listRefs(dir, "refs/heads/");
@@ -322,6 +325,7 @@ export async function settingsPage(ctx: Ctx): Promise<Response> {
322325 </form>`,
323326 { head: html`${icon("rocket")}<h2>Cut a wave</h2>` },
324327 );
328 const proposals = proposalTokensSection(ctx, repo);
325329 const archive = card(
326330 html`<form method="post" action="${base}/settings/archive">
327331 <input type="hidden" name="archived" value="${repo.archivedAt === null ? "1" : "0"}" />
@@ -343,7 +347,9 @@ export async function settingsPage(ctx: Ctx): Promise<Response> {
343347 repo,
344348 "general",
345349 "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
347353 ? html`<div class="mt">${secrets}</div>`
348354 : null}`,
349355 );
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 /-/confirm?to=… from the link on the page, expect 200, then POST.

119 const confirm = await fetch(`${base}${link!.replaceAll("&amp;", "&")}`, { 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 Bad Name was refused. Check the danger flash, or that no new row appears.

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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs