Public: anyone can read this; the crew can change it.
The pinned forges page updates, rolls back and checks a forge, each after a confirm #10176
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10107-the-pinned-forges-page-updates-rolls-bac → main 3 commits fork 1 thread · 1 unresolved
5 files
changed +153
−3
docs/features/pinned-forge.md +1 −1
| @@ -7,7 +7,7 @@ A pinned forge is a forge the owner put on their own cloud. It has **no crew** a | ||
| 7 | 7 | - HTTP: `GET /api/v1/admin/pinned` (forges, their changes, the targets), `POST /api/v1/admin/pinned` (deploy), `POST /api/v1/admin/pinned/:name/update`, `POST /api/v1/admin/pinned/:name/rollback`. |
| 8 | 8 | - Terminal: palette → **Pinned forges** (forge admin): `d` deploy, `u` update, `r` roll back; it follows the bus, so a change made at any door shows at once (`src/tui/screens/pinned.ts`). |
| 9 | 9 | - Web: `/deploy-to` (forge admin; linked from Cloud accounts) is a server-rendered form for cloud account, app, source, version and name. The link `/deploy-to?app=owner/name&source=https://…&version=v1.2.3` (`deployToHref` in `src/web/pinned.ts`) lands on it with those filled in after one sign-in; nothing deploys until the button is pressed. The POST is same-origin checked and calls the same `deployPinned` (`src/http/pinned_routes.ts`). |
| 10 | - Web list: `/pinned` (forge admin; 404 otherwise) is server-rendered, one block of plain rows per forge: name, app, version, address (a link), health (the state), when the last check ran (the newest `pinned.check` event; `never checked` before the first), and the last change with who and when, from the same `pinnedForges` read as the API (`src/web/pages/pinned.ts`). The intro links `/deploy-to`; an empty list says so. | |
| 10 | - Web list: `/pinned` (forge admin; 404 otherwise) is server-rendered, with actions; one block of plain rows per forge: name, app, version, address (a link), health (the state), when the last check ran (the newest `pinned.check` event; `never checked` before the first), and the last change with who and when, from the same `pinnedForges` read as the API (`src/web/pages/pinned.ts`). The intro links `/deploy-to`; an empty list says so. A person can update, roll back and check a forge from `/pinned`, as the terminal's `u`, `r` and `c` do: each row has three buttons (Update, Roll back, Check), and each goes through the confirm page before it runs (Update also asks for the version). They post to `POST /pinned/:name/:act` (`act` is `update`, `rollback` or `check`; admin only, same-origin, 404 for non-admins and unknown acts); the result, or the sentence that refuses it, is shown as a flash on `/pinned` (`src/http/pinned_routes.ts`). | |
| 11 | 11 | - MCP: `pinned_forge` with `verb` `list`, `deploy`, `update`, `rollback` (and `check`) — the same words and the same checks (`src/actions/pinned.ts`). |
| 12 | 12 | |
| 13 | 13 | A deploy names a cloud account (Settings → Cloud accounts, **writes allowed**), the app (`owner/name`), a git `source` (https) and a `version` (a 40-character commit id or a tag like `v1.2.3` — never a branch, so nothing moves under it). |
src/http/pinned_routes.ts +27 −1
| @@ -5,7 +5,7 @@ | ||
| 5 | 5 | * `deployPinned` the API, MCP and terminal call, admin only and same-origin. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { deployPinned, pinnedForges } from "../actions/pinned.ts"; | |
| 8 | import { checkPinned, deployPinned, pinnedForges, rollbackPinned, updatePinned } from "../actions/pinned.ts"; | |
| 9 | 9 | import { listCloudAccounts } from "../core/cloud_account.ts"; |
| 10 | 10 | import { lastCheckOf } from "../core/pinned.ts"; |
| 11 | 11 | import { getUsersByIds } from "../core/users.ts"; |
| @@ -71,8 +71,34 @@ function listPage(ctx: Ctx): Response { | ||
| 71 | 71 | return page({ ctx, title: "Pinned forges", body: pinnedListPage(forges.value, { names, checks }) }); |
| 72 | 72 | } |
| 73 | 73 | |
| 74 | /** Update, Roll back and Check: the same actions the terminal's `u`, `r` and `c` call; admin only, same-origin, each refusal shown as its sentence. */ | |
| 75 | async function pinnedAct(ctx: Ctx): Promise<Response> { | |
| 76 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 77 | if (!ctx.auth.user.isAdmin) return notFoundPage(ctx); | |
| 78 | const form = await readForm(ctx, "admin"); | |
| 79 | if (form instanceof Response) return form; | |
| 80 | const name = ctx.params.name ?? ""; | |
| 81 | const act = ctx.params.act ?? ""; | |
| 82 | const user = ctx.auth.user; | |
| 83 | let done; | |
| 84 | if (act === "update") done = await updatePinned(ctx.app, user, name, { version: field(form, "version").trim().slice(0, 40) }); | |
| 85 | else if (act === "rollback") done = await rollbackPinned(ctx.app, user, name); | |
| 86 | else if (act === "check") done = await checkPinned(ctx.app, user, name); | |
| 87 | else return notFoundPage(ctx); | |
| 88 | if (!done.ok) return flashTo(ctx, "/pinned", { tone: "danger", message: done.error }); | |
| 89 | const at = done.value.version.slice(0, 12); | |
| 90 | const message = | |
| 91 | act === "check" | |
| 92 | ? `${name} ${"healthy" in done.value && done.value.healthy ? "answers" : "did not answer"}; it is ${done.value.state}.` | |
| 93 | : act === "update" | |
| 94 | ? `Updated ${name} to ${at}.` | |
| 95 | : `Rolled ${name} back to ${at}.`; | |
| 96 | return flashTo(ctx, "/pinned", { tone: "success", message }); | |
| 97 | } | |
| 98 | ||
| 74 | 99 | export function pinnedRoutes(router: Router<Handler>): void { |
| 75 | 100 | router.add("GET", "/pinned", listPage); |
| 101 | router.add("POST", "/pinned/:name/:act", pinnedAct); | |
| 76 | 102 | router.add("GET", "/deploy-to", deployToPage); |
| 77 | 103 | router.add("POST", "/deploy-to", deployToSubmit); |
| 78 | 104 | } |
src/web/pages/pinned.ts +22 −1
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | |
| 7 | 7 | import type { PinnedView } from "../../actions/pinned.ts"; |
| 8 | 8 | import { html, type Html } from "../html.ts"; |
| 9 | import { badge, time, type Tone } from "../ui.ts"; | |
| 9 | import { badge, btn, time, type Tone } from "../ui.ts"; | |
| 10 | 10 | |
| 11 | 11 | const TONE: Record<string, Tone> = { running: "success", failed: "danger" }; |
| 12 | 12 | |
| @@ -19,6 +19,26 @@ export interface PinnedFacts { | ||
| 19 | 19 | const fact = (label: string, value: Html): Html => html`<div class="status-row"><dt>${label}</dt><dd>${value}</dd></div>`; |
| 20 | 20 | const none = (text: string): Html => html`<span class="muted">${text}</span>`; |
| 21 | 21 | |
| 22 | /** Update asks for the version, Roll back and Check for nothing; each goes through the confirm page, which posts to `/pinned/<name>/<act>`. */ | |
| 23 | function actions(p: PinnedView): Html { | |
| 24 | const to = (act: string): string => `/pinned/${p.name}/${act}`; | |
| 25 | return html`<div class="form-actions"> | |
| 26 | <form method="get" action="/-/confirm"> | |
| 27 | <input type="hidden" name="to" value="${to("update")}" /> | |
| 28 | <input class="mono" name="version" value="${p.version}" maxlength="40" required spellcheck="false" autocomplete="off" aria-label="Version for ${p.name}" /> | |
| 29 | ${btn("Update", { variant: "secondary" })} | |
| 30 | </form> | |
| 31 | <form method="get" action="/-/confirm"> | |
| 32 | <input type="hidden" name="to" value="${to("rollback")}" /> | |
| 33 | ${btn("Roll back", { variant: "secondary", label: `Roll back ${p.name}` })} | |
| 34 | </form> | |
| 35 | <form method="get" action="/-/confirm"> | |
| 36 | <input type="hidden" name="to" value="${to("check")}" /> | |
| 37 | ${btn("Check", { variant: "ghost", label: `Check ${p.name}` })} | |
| 38 | </form> | |
| 39 | </div>`; | |
| 40 | } | |
| 41 | ||
| 22 | 42 | function forge(p: PinnedView, f: PinnedFacts): Html { |
| 23 | 43 | const last = p.changes[0]; |
| 24 | 44 | const checked = f.checks.get(p.name); |
| @@ -33,6 +53,7 @@ function forge(p: PinnedView, f: PinnedFacts): Html { | ||
| 33 | 53 | ${fact("Last change", last ? html`${last.kind} to <span class="mono">${last.toVersion.slice(0, 12)}</span>` : none("none"))} |
| 34 | 54 | ${last ? fact("Changed by", html`${f.names.get(last.by) ?? last.by} ${time(last.at)}`) : null} |
| 35 | 55 | </dl> |
| 56 | ${actions(p)} | |
| 36 | 57 | </section>`; |
| 37 | 58 | } |
| 38 | 59 |
src/web/pages/search.ts +27
| @@ -866,6 +866,33 @@ const DESTRUCTIVE: { | ||
| 866 | 866 | }; |
| 867 | 867 | }, |
| 868 | 868 | }, |
| 869 | { | |
| 870 | pattern: /^\/pinned\/([a-z][a-z0-9-]*)\/(update|rollback|check)$/, | |
| 871 | describe: (m) => { | |
| 872 | const name = m[1]!; | |
| 873 | if (m[2] === "update") | |
| 874 | return { | |
| 875 | verb: "Update", | |
| 876 | title: `Update ${name}?`, | |
| 877 | detail: "The forge is replaced with the version you named. It changes only when you press this, and the change is recorded with your name.", | |
| 878 | phrase: "", | |
| 879 | carries: "version", | |
| 880 | }; | |
| 881 | if (m[2] === "rollback") | |
| 882 | return { | |
| 883 | verb: "Roll back", | |
| 884 | title: `Roll ${name} back?`, | |
| 885 | detail: "The forge is replaced with the version the latest change replaced. The change is recorded with your name.", | |
| 886 | phrase: "", | |
| 887 | }; | |
| 888 | return { | |
| 889 | verb: "Check", | |
| 890 | title: `Check ${name}?`, | |
| 891 | detail: "The forge reads its droplet's address back and asks the forge there for its front page. Nothing is changed but what it says about itself.", | |
| 892 | phrase: "", | |
| 893 | }; | |
| 894 | }, | |
| 895 | }, | |
| 869 | 896 | { |
| 870 | 897 | pattern: /^\/admin\/invites\/([^/]+)\/revoke$/, |
| 871 | 898 | describe: (m) => ({ |
test/pinned-page.test.ts +76
| @@ -158,3 +158,79 @@ describe("pinned list page", () => { | ||
| 158 | 158 | } |
| 159 | 159 | }, 30_000); |
| 160 | 160 | }); |
| 161 | ||
| 162 | describe("pinned list page actions", () => { | |
| 163 | test("Update, Roll back and Check go through a confirm, are admin only and same-origin, and show refusals", async () => { | |
| 164 | const dataDir = await mkdtemp(join(tmpdir(), "forge-pinned-acts-")); | |
| 165 | const app = createApp({ dataDir, port: 0 }); | |
| 166 | const servers = await serve(app); | |
| 167 | const V2 = "b".repeat(40); | |
| 168 | try { | |
| 169 | for (const [username, adminIfFirst] of [["alice", true], ["bob", false]] as const) { | |
| 170 | const made = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@f.test`, password: "a-good-passphrase", adminIfFirst }); | |
| 171 | if (!made.ok) throw new Error(made.error); | |
| 172 | } | |
| 173 | const adminTok = createToken(app.db, getUserByName(app.db, "alice")!.id, "admin", "admin").token; | |
| 174 | const bobTok = createToken(app.db, getUserByName(app.db, "bob")!.id, "admin", "admin").token; | |
| 175 | cloudNet.fetch = async () => new Response(JSON.stringify({ account: { email: "[email protected]", status: "active" } }), { status: 200 }); | |
| 176 | const added = await addAccount(app, getUserByName(app.db, "alice"), "digitalocean", "main", "dop_v1_9Zq4TxLm7Kp2Vb8nWcR5"); | |
| 177 | if (!added.ok) throw new Error(String(added.error)); | |
| 178 | allowWrite(app, getUserByName(app.db, "alice"), "digitalocean", "main", true); | |
| 179 | const calls: string[] = []; | |
| 180 | net.fetch = async (url, init) => { | |
| 181 | const path = new URL(url).pathname; | |
| 182 | calls.push(`${init?.method ?? "GET"} ${path}`); | |
| 183 | if (path === "/v2/volumes") return new Response(JSON.stringify({ volume: { id: "vol-1" } }), { status: 201 }); | |
| 184 | if (init?.method === "DELETE") return new Response(null, { status: 204 }); | |
| 185 | return new Response(JSON.stringify({ droplet: { id: 7, networks: { v4: [{ type: "public", ip_address: "203.0.113.9" }] } } }), { status: 202 }); | |
| 186 | }; | |
| 187 | net.probe = async () => new Response("ok", { status: 200 }); | |
| 188 | const hdr = (tok: string, origin = servers.url) => ({ authorization: `Bearer ${tok}`, origin, "content-type": "application/x-www-form-urlencoded" }); | |
| 189 | const act = (path: string, tok: string, body: Record<string, string> = {}, origin?: string) => | |
| 190 | fetch(servers.url + path, { method: "POST", redirect: "manual", headers: hdr(tok, origin), body: new URLSearchParams(body) }); | |
| 191 | const get = (path: string, tok: string) => fetch(servers.url + path, { redirect: "manual", headers: { authorization: `Bearer ${tok}` } }); | |
| 192 | const after = async (r: Response) => | |
| 193 | (await fetch(servers.url + r.headers.get("location")!, { headers: { authorization: `Bearer ${adminTok}`, cookie: (r.headers.get("set-cookie") ?? "").split(";")[0]! } })).text(); | |
| 194 | const dep = await act("/deploy-to", adminTok, { name: "acme", account: "main", app: "alice/shop", source: SOURCE, version: V }); | |
| 195 | expect(dep.status).toBe(303); | |
| 196 | ||
| 197 | const list = await (await get("/pinned", adminTok)).text(); | |
| 198 | for (const w of ["/pinned/acme/update", "/pinned/acme/rollback", "/pinned/acme/check"]) expect(list).toContain(w); | |
| 199 | ||
| 200 | // The confirm page words each and carries the version to the POST. | |
| 201 | const conf = await (await get(`/-/confirm?to=${encodeURIComponent("/pinned/acme/update")}&version=${V2}`, adminTok)).text(); | |
| 202 | expect(conf).toContain('action="/pinned/acme/update"'); | |
| 203 | expect(conf).toContain(`value="${V2}"`); | |
| 204 | expect((await get("/-/confirm?to=/pinned/Bad_Name/update", adminTok)).status).toBe(404); | |
| 205 | ||
| 206 | // Not an admin: 404 and nothing moves. Cross-origin: 403. | |
| 207 | for (const a of ["update", "rollback", "check"]) expect((await act(`/pinned/acme/${a}`, bobTok, { version: V2 })).status).toBe(404); | |
| 208 | expect((await act("/pinned/acme/update", adminTok, { version: V2 }, "https://evil.example")).status).toBe(403); | |
| 209 | expect(getPinned(app.db, "acme")!.version).toBe(V); | |
| 210 | ||
| 211 | // A refusal is shown as its sentence. | |
| 212 | const refused = await act("/pinned/acme/rollback", adminTok); | |
| 213 | expect(refused.status).toBe(303); | |
| 214 | const shown = await after(refused); | |
| 215 | expect(shown).toContain("There is no previous version"); | |
| 216 | const bad = await act("/pinned/acme/update", adminTok, { version: "main" }); | |
| 217 | expect(await after(bad)).toContain("A version is a full 40-character commit id"); | |
| 218 | ||
| 219 | // Update, then roll back, then check. | |
| 220 | expect((await act("/pinned/acme/update", adminTok, { version: V2 })).status).toBe(303); | |
| 221 | expect(getPinned(app.db, "acme")!.version).toBe(V2); | |
| 222 | expect((await act("/pinned/acme/rollback", adminTok)).status).toBe(303); | |
| 223 | expect(getPinned(app.db, "acme")!.version).toBe(V); | |
| 224 | const before = calls.length; | |
| 225 | const chk = await act("/pinned/acme/check", adminTok); | |
| 226 | expect(chk.status).toBe(303); | |
| 227 | expect(calls.length).toBeGreaterThan(before); | |
| 228 | const checked = await (await get("/pinned", adminTok)).text(); | |
| 229 | expect(checked).not.toContain("never checked"); | |
| 230 | expect((await act("/pinned/nope/check", adminTok)).status).toBe(303); | |
| 231 | } finally { | |
| 232 | await servers.stop(); | |
| 233 | await rm(dataDir, { recursive: true, force: true }); | |
| 234 | } | |
| 235 | }, 30_000); | |
| 236 | }); |
Add this route and the three row buttons to the 'Web list' paragraph of docs/features/pinned-forge.md. The how-to must say a person can update, roll back and check from
/pinned, as the terminal'su,randcdo. The docs are the only miss in this pull.