Public: anyone can read this; the crew can change it.
A Deploy to… page and link for a pinned forge (follow-up to #8697) #8794
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8744-a-deploy-to-page-and-link-for-a-pinned-f → main 5 commits fork 3 threads · 3 unresolved
8 files
changed +235
−3
docs/features/pinned-forge.md +2 −2
| @@ -6,6 +6,7 @@ A pinned forge is a forge the owner put on their own cloud. It has **no crew** a | ||
| 6 | 6 | |
| 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 | - 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`). | |
| 9 | 10 | - MCP: `pinned_forge` with `verb` `list`, `deploy`, `update`, `rollback` (and `check`) — the same words and the same checks (`src/actions/pinned.ts`). |
| 10 | 11 | |
| 11 | 12 | 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). |
| @@ -47,9 +48,8 @@ Each id is recorded the moment it exists. A first deploy that fails before a dro | ||
| 47 | 48 | ## Not done |
| 48 | 49 | |
| 49 | 50 | - No canary result on a real account yet: the script is written, the run needs an owner's token (#8746, see Canary). |
| 50 | - No `Deploy to…` page or link: #8744. | |
| 51 | 51 | - No TLS or domain: the URL is `http://<droplet IPv4>:<PORT or 3000>/`. |
| 52 | - No page in the web studio shows the pinned forges yet; the API, MCP and terminal do. | |
| 52 | - No page in the web studio lists the pinned forges yet; `/deploy-to` deploys one, and the API, MCP and terminal list them. | |
| 53 | 53 | - AWS and Azure drivers. |
| 54 | 54 | |
| 55 | 55 | ## Canary (#8746) |
src/core/reserved.ts +1
| @@ -65,6 +65,7 @@ export const SYSTEM_NAMES: readonly string[] = [ | ||
| 65 | 65 | "board", |
| 66 | 66 | "boards", |
| 67 | 67 | "contribute", |
| 68 | "deploy-to", | |
| 68 | 69 | "pipeline", |
| 69 | 70 | "events", |
| 70 | 71 | "explore", |
src/http/pinned_routes.ts added +60
| @@ -0,0 +1,60 @@ | ||
| 1 | /** | |
| 2 | * `/deploy-to` (#8744): the web door to a pinned forge. GET shows the form, | |
| 3 | * filled from `app`, `source` and `version` in the link; signed out, it sends | |
| 4 | * the visitor through one sign-in and back to the same link. POST is the same | |
| 5 | * `deployPinned` the API, MCP and terminal call, admin only and same-origin. | |
| 6 | */ | |
| 7 | ||
| 8 | import { deployPinned } from "../actions/pinned.ts"; | |
| 9 | import { listCloudAccounts } from "../core/cloud_account.ts"; | |
| 10 | import { notFoundPage, signInFirst } from "../web/chrome.ts"; | |
| 11 | import { field, readForm } from "../web/form.ts"; | |
| 12 | import { flashTo, page } from "../web/layout.ts"; | |
| 13 | import { deployPage, nameFor, type DeployForm } from "../web/pinned.ts"; | |
| 14 | import type { Handler, Ctx } from "./context.ts"; | |
| 15 | import type { Router } from "./router.ts"; | |
| 16 | ||
| 17 | const seen = (get: (k: string) => string): DeployForm => { | |
| 18 | const app = get("app").trim().slice(0, 140); | |
| 19 | return { | |
| 20 | app, | |
| 21 | source: get("source").trim().slice(0, 400), | |
| 22 | version: get("version").trim().slice(0, 40), | |
| 23 | account: get("account").trim().slice(0, 40), | |
| 24 | name: get("name").trim().slice(0, 40) || nameFor(app), | |
| 25 | }; | |
| 26 | }; | |
| 27 | ||
| 28 | function render(ctx: Ctx, v: DeployForm, flash?: { tone: "danger"; message: string }): Response { | |
| 29 | return page( | |
| 30 | { | |
| 31 | ctx, | |
| 32 | title: "Deploy to…", | |
| 33 | body: deployPage(listCloudAccounts(ctx.app.db), v), | |
| 34 | ...(flash ? { flash } : {}), | |
| 35 | }, | |
| 36 | flash ? 422 : 200, | |
| 37 | ); | |
| 38 | } | |
| 39 | ||
| 40 | function deployToPage(ctx: Ctx): Response { | |
| 41 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 42 | if (!ctx.auth.user.isAdmin) return notFoundPage(ctx); | |
| 43 | return render(ctx, seen((k) => ctx.url.searchParams.get(k) ?? "")); | |
| 44 | } | |
| 45 | ||
| 46 | async function deployToSubmit(ctx: Ctx): Promise<Response> { | |
| 47 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 48 | if (!ctx.auth.user.isAdmin) return notFoundPage(ctx); | |
| 49 | const form = await readForm(ctx, "admin"); | |
| 50 | if (form instanceof Response) return form; | |
| 51 | const v = seen((k) => field(form, k)); | |
| 52 | const made = await deployPinned(ctx.app, ctx.auth.user, { ...v, target: "digitalocean" }); | |
| 53 | if (!made.ok) return render(ctx, v, { tone: "danger", message: made.error }); | |
| 54 | return flashTo(ctx, "/deploy-to", { message: `Deployed ${made.value.name} at ${made.value.version.slice(0, 12)}. It changes only when you press Update.` }); | |
| 55 | } | |
| 56 | ||
| 57 | export function pinnedRoutes(router: Router<Handler>): void { | |
| 58 | router.add("GET", "/deploy-to", deployToPage); | |
| 59 | router.add("POST", "/deploy-to", deployToSubmit); | |
| 60 | } |
src/http/server.ts +3
| @@ -22,6 +22,7 @@ import { resiliencePage } from "../web/pages/resilience.ts"; | ||
| 22 | 22 | import { limitsPage, limitsSet, quotasSet, signupSet, inviteMint, inviteRevoke } from "../web/pages/admin-limits.ts"; |
| 23 | 23 | import { measureSubmit } from "./measure.ts"; |
| 24 | 24 | import { settingsRoutes } from "./settings_routes.ts"; |
| 25 | import { pinnedRoutes } from "./pinned_routes.ts"; | |
| 25 | 26 | import { boardsPage } from "../web/pages/boards.ts"; |
| 26 | 27 | import { boardNew, boardPin, boardPost, pinPost } from "../web/pages/boards-edit.ts"; |
| 27 | 28 | import { boardsHoldPost } from "../web/pages/boards-holds.ts"; |
| @@ -790,6 +791,8 @@ export function buildRouter(): Router<Handler> { | ||
| 790 | 791 | // Every /settings… page, in its own module (settings_routes.ts); literal, above /:owner. |
| 791 | 792 | // The usage page is handed the pattern list, so settings_routes.ts never imports server.ts. |
| 792 | 793 | settingsRoutes(router, knownPatterns); |
| 794 | // The Deploy to… page for a pinned forge (#8744); literal, above /:owner. | |
| 795 | pinnedRoutes(router); | |
| 793 | 796 | // Each THREATS.md protection against the last gate; literal, above /:owner. |
| 794 | 797 | router.add("GET", "/resilience", resiliencePage); |
| 795 | 798 | // A selection's spend and runs over time; literal, above /:owner. |
src/web/pages/cloud.ts +1 −1
| @@ -161,7 +161,7 @@ export async function cloudPage(ctx: Ctx): Promise<Response> { | ||
| 161 | 161 | ${settingsLayout( |
| 162 | 162 | accountSections("cloud", { admin: true }), |
| 163 | 163 | html`${accountsCard(accounts, reads, uses)}<div class="mt">${readsCard(reads)}</div> |
| 164 | <p class="muted small mt"><a href="/settings/cloud/audit">Every vendor call today</a></p>`, | |
| 164 | <p class="muted small mt"><a href="/settings/cloud/audit">Every vendor call today</a> · <a href="/deploy-to">Deploy a pinned forge</a></p>`, | |
| 165 | 165 | "Account settings", |
| 166 | 166 | )}`, |
| 167 | 167 | }); |
src/web/pinned.ts added +58
| @@ -0,0 +1,58 @@ | ||
| 1 | /** | |
| 2 | * The Deploy to… page (#8744): a form that puts a pinned forge on the owner's | |
| 3 | * cloud. A link carrying `app`, `source` and `version` lands here with those | |
| 4 | * filled in; nothing is deployed until the admin presses the button. | |
| 5 | */ | |
| 6 | ||
| 7 | import type { CloudAccount } from "../core/cloud_account.ts"; | |
| 8 | import { LABEL } from "../cloud/providers.ts"; | |
| 9 | import { html, type Html } from "./html.ts"; | |
| 10 | import { btn, card, field, selectField } from "./ui.ts"; | |
| 11 | ||
| 12 | export interface DeployForm { | |
| 13 | name: string; | |
| 14 | account: string; | |
| 15 | app: string; | |
| 16 | source: string; | |
| 17 | version: string; | |
| 18 | } | |
| 19 | ||
| 20 | /** Name a new forge after the app it runs, when the link does not say. */ | |
| 21 | export const nameFor = (app: string): string => { | |
| 22 | const base = (app.split("/")[1] ?? "").toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^[^a-z]+/, "").slice(0, 40); | |
| 23 | return base; | |
| 24 | }; | |
| 25 | ||
| 26 | /** The link a README or page carries: sign-in is the route's, not the link's. */ | |
| 27 | export function deployToHref(o: { app: string; source: string; version: string }): string { | |
| 28 | const q = new URLSearchParams({ app: o.app, source: o.source, version: o.version }); | |
| 29 | return `/deploy-to?${q}`; | |
| 30 | } | |
| 31 | ||
| 32 | export function deployPage(accounts: CloudAccount[], v: DeployForm): Html { | |
| 33 | const usable = accounts.filter((a) => a.provider === "digitalocean" && a.writeAllowed); | |
| 34 | return html`<div class="page-head"><h1>Deploy to…</h1></div> | |
| 35 | ${card( | |
| 36 | html`<p class="muted small"> | |
| 37 | A pinned forge runs one version you choose, on a DigitalOcean droplet in your account. It has no crew and | |
| 38 | never updates itself: it changes only when you press Update, and Rollback is one action back. | |
| 39 | </p> | |
| 40 | ${usable.length === 0 | |
| 41 | ? html`<p class="small" role="status">No cloud account can write yet. Add a DigitalOcean account and allow writes on | |
| 42 | <a href="/settings/cloud">Cloud accounts</a>, then come back to this page: what the link carried is kept.</p>` | |
| 43 | : null} | |
| 44 | <form method="post" action="/deploy-to" class="stack"> | |
| 45 | ${selectField({ | |
| 46 | name: "account", | |
| 47 | label: "Cloud account", | |
| 48 | required: true, | |
| 49 | options: usable.map((a) => ({ value: a.name, label: `${LABEL[a.provider]} / ${a.name}`, selected: a.name === v.account })), | |
| 50 | })} | |
| 51 | ${field({ name: "app", label: "App", value: v.app, placeholder: "owner/name", mono: true, required: true, maxlength: 140, spellcheck: false })} | |
| 52 | ${field({ name: "source", label: "Source", value: v.source, placeholder: "https://…", mono: true, required: true, maxlength: 400, spellcheck: false, hint: "An https git address." })} | |
| 53 | ${field({ name: "version", label: "Version", value: v.version, mono: true, required: true, maxlength: 40, spellcheck: false, hint: "A full 40-character commit id or a tag like v1.2.3 — never a branch." })} | |
| 54 | ${field({ name: "name", label: "Name", value: v.name, mono: true, required: true, maxlength: 40, spellcheck: false, hint: "Lowercase letters, digits and dashes." })} | |
| 55 | ${btn("Deploy", { variant: "primary", icon: "plus" })} | |
| 56 | </form>`, | |
| 57 | )}`; | |
outdated
This card head h2 repeats the page h1. Keep one heading. |
||
| 58 | } |
test/forge-looks.test.ts +1
| @@ -20,6 +20,7 @@ quietLogs(); | ||
| 20 | 20 | |
| 21 | 21 | /** Top-level routes that are not pages, or are not worth a picture, and why. */ |
| 22 | 22 | const NOT_A_PAGE: Record<string, string> = { |
| 23 | "/deploy-to": "a form for a forge admin that fills from its link; covered by test/pinned-page.test.ts", | |
| 23 | 24 | "/asked": "no picture: the cap of 24 looks went to /notes, the page that now leads with a person's thoughts; /asked is covered by test/asked.test.ts", |
| 24 | 25 | "/llms.txt": "plain markdown for machines, not a page to picture", |
| 25 | 26 | "/robots.txt": "plain text for crawlers", |
test/pinned-page.test.ts added +109
| @@ -0,0 +1,109 @@ | ||
| 1 | /** The Deploy to… page (#8744): one sign-in, a filled form, admin only, same-origin, and the same deploy as the other doors. */ | |
| 2 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { addAccount, allowWrite, net as cloudNet } from "../src/actions/cloud.ts"; | |
| 8 | import { net } from "../src/actions/pinned.ts"; | |
| 9 | import { getPinned } from "../src/core/pinned.ts"; | |
| 10 | import { createToken } from "../src/core/tokens.ts"; | |
| 11 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 12 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 13 | import { deployToHref } from "../src/web/pinned.ts"; | |
| 14 | import { quietLogs } from "./helpers/globals.ts"; | |
| 15 | ||
| 16 | quietLogs(); | |
| 17 | ||
| 18 | const V = "a".repeat(40); | |
| 19 | const SOURCE = "https://forge.example.org/trevato/forge"; | |
| 20 | ||
| 21 | describe("deploy-to page", () => { | |
| 22 | let dataDir = ""; | |
| 23 | let app: App; | |
| 24 | let servers: Servers; | |
| 25 | let adminTok = ""; | |
| 26 | let bobTok = ""; | |
| 27 | const base = () => servers.url; | |
| 28 | const get = (path: string, tok?: string) => | |
| 29 | fetch(base() + path, { redirect: "manual", headers: tok ? { authorization: `Bearer ${tok}` } : {} }); | |
| 30 | const post = (body: Record<string, string>, tok: string, origin = base()) => | |
| 31 | fetch(base() + "/deploy-to", { | |
| 32 | method: "POST", redirect: "manual", | |
| 33 | headers: { authorization: `Bearer ${tok}`, origin, "content-type": "application/x-www-form-urlencoded" }, | |
| 34 | body: new URLSearchParams(body), | |
| 35 | }); | |
| 36 | const good = { name: "acme", account: "main", app: "alice/shop", source: SOURCE, version: V }; | |
| 37 | ||
| 38 | beforeAll(async () => { | |
| 39 | dataDir = await mkdtemp(join(tmpdir(), "forge-pinned-page-")); | |
| 40 | app = createApp({ dataDir, port: 0 }); | |
| 41 | servers = await serve(app); | |
| 42 | for (const [username, adminIfFirst] of [["alice", true], ["bob", false]] as const) { | |
| 43 | const made = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@f.test`, password: "a-good-passphrase", adminIfFirst }); | |
| 44 | if (!made.ok) throw new Error(made.error); | |
| 45 | } | |
| 46 | adminTok = createToken(app.db, getUserByName(app.db, "alice")!.id, "admin", "admin").token; | |
| 47 | bobTok = createToken(app.db, getUserByName(app.db, "bob")!.id, "admin", "admin").token; | |
| 48 | cloudNet.fetch = async () => new Response(JSON.stringify({ account: { email: "[email protected]", status: "active" } }), { status: 200 }); | |
| 49 | const added = await addAccount(app, getUserByName(app.db, "alice"), "digitalocean", "main", "dop_v1_9Zq4TxLm7Kp2Vb8nWcR5"); | |
| 50 | if (!added.ok) throw new Error(String(added.error)); | |
| 51 | net.fetch = async (url, init) => { | |
| 52 | if (new URL(url).pathname === "/v2/volumes") return new Response(JSON.stringify({ volume: { id: "vol-1" } }), { status: 201 }); | |
| 53 | if (init?.method === "DELETE") return new Response(null, { status: 204 }); | |
| 54 | return new Response(JSON.stringify({ droplet: { id: 7 } }), { status: 202 }); | |
| 55 | }; | |
| 56 | }, 30_000); | |
| 57 | ||
| 58 | afterAll(async () => { | |
| 59 | await servers.stop(); | |
| 60 | await rm(dataDir, { recursive: true, force: true }); | |
| 61 | }); | |
| 62 | ||
| 63 | test("signed out, the link goes to sign-in and comes back to itself", async () => { | |
| 64 | const link = deployToHref({ app: "alice/shop", source: SOURCE, version: V }); | |
| 65 | const r = await get(link); | |
| 66 | expect(r.status).toBe(303); | |
| 67 | const to = r.headers.get("location")!; | |
| 68 | expect(to).toContain("/signin"); | |
| 69 | expect(decodeURIComponent(to)).toContain(link); | |
| 70 | }); | |
| 71 | ||
| 72 | test("an admin sees the form filled from the link, escaped", async () => { | |
| 73 | const r = await get(deployToHref({ app: 'alice/shop"><script>', source: SOURCE, version: V }), adminTok); | |
| 74 | expect(r.status).toBe(200); | |
| 75 | const t = await r.text(); | |
| 76 | expect(t).toContain(`value="${V}"`); | |
| 77 | expect(t).toContain("alice/shop"><script>"); | |
| 78 | expect(t).toContain("No cloud account can write yet"); | |
| 79 | expect(t).not.toContain("<script>"); | |
| 80 | }); | |
| 81 | ||
| 82 | test("someone who is not an admin finds nothing", async () => { | |
| 83 | expect((await get("/deploy-to", bobTok)).status).toBe(404); | |
| 84 | expect((await post(good, bobTok)).status).toBe(404); | |
| 85 | expect(getPinned(app.db, "acme")).toBeUndefined(); | |
| 86 | }); | |
| 87 | ||
| 88 | test("a cross-origin post is refused", async () => { | |
| 89 | expect((await post(good, adminTok, "https://evil.example")).status).toBe(403); | |
| 90 | expect(getPinned(app.db, "acme")).toBeUndefined(); | |
| 91 | }); | |
| 92 | ||
| 93 | test("a bad version re-renders the form with the reason and deploys nothing", async () => { | |
| 94 | const r = await post({ ...good, version: "main" }, adminTok); | |
| 95 | expect(r.status).toBe(422); | |
| 96 | expect(await r.text()).toContain('value="alice/shop"'); | |
| 97 | expect(getPinned(app.db, "acme")).toBeUndefined(); | |
| 98 | }); | |
| 99 | ||
| 100 | test("with writes allowed the account is offered and a post deploys", async () => { | |
| 101 | allowWrite(app, getUserByName(app.db, "alice"), "digitalocean", "main", true); | |
| 102 | expect(await (await get("/deploy-to", adminTok)).text()).toContain("DigitalOcean / main"); | |
| 103 | const r = await post(good, adminTok); | |
| 104 | expect(r.status).toBe(303); | |
| 105 | const p = getPinned(app.db, "acme")!; | |
| 106 | expect(p.version).toBe(V); | |
| 107 | expect(p.state).toBe("running"); | |
| 108 | }); | |
| 109 | }); |
new above
offline
errors is never passed by the route, so this parameter and the errors.* spreads below are dead. Remove them.