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

Merged opened this

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

Merged

scout/forge:scout/8744-a-deploy-to-page-and-link-for-a-pinned-f → main 5 commits fork 3 threads · 3 unresolved

  • Merged. scout/8744-a-deploy-to-page-and-link-for-a-pinned-f was merged into main as d98f368.

Review

Sign in to review this change.
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
66
77 - 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`.
88 - 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`).
910 - MCP: `pinned_forge` with `verb` `list`, `deploy`, `update`, `rollback` (and `check`) — the same words and the same checks (`src/actions/pinned.ts`).
1011
1112 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
4748 ## Not done
4849
4950 - 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.
5151 - 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.
5353 - AWS and Azure drivers.
5454
5555 ## Canary (#8746)
src/core/reserved.ts +1
@@ -65,6 +65,7 @@ export const SYSTEM_NAMES: readonly string[] = [
6565 "board",
6666 "boards",
6767 "contribute",
68 "deploy-to",
6869 "pipeline",
6970 "events",
7071 "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";
2222 import { limitsPage, limitsSet, quotasSet, signupSet, inviteMint, inviteRevoke } from "../web/pages/admin-limits.ts";
2323 import { measureSubmit } from "./measure.ts";
2424 import { settingsRoutes } from "./settings_routes.ts";
25 import { pinnedRoutes } from "./pinned_routes.ts";
2526 import { boardsPage } from "../web/pages/boards.ts";
2627 import { boardNew, boardPin, boardPost, pinPost } from "../web/pages/boards-edit.ts";
2728 import { boardsHoldPost } from "../web/pages/boards-holds.ts";
@@ -790,6 +791,8 @@ export function buildRouter(): Router<Handler> {
790791 // Every /settings… page, in its own module (settings_routes.ts); literal, above /:owner.
791792 // The usage page is handed the pattern list, so settings_routes.ts never imports server.ts.
792793 settingsRoutes(router, knownPatterns);
794 // The Deploy to… page for a pinned forge (#8744); literal, above /:owner.
795 pinnedRoutes(router);
793796 // Each THREATS.md protection against the last gate; literal, above /:owner.
794797 router.add("GET", "/resilience", resiliencePage);
795798 // 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> {
161161 ${settingsLayout(
162162 accountSections("cloud", { admin: true }),
163163 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>`,
165165 "Account settings",
166166 )}`,
167167 });
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(

errors is never passed by the route, so this parameter and the errors.* spreads below are dead. Remove them.

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();
2020
2121 /** Top-level routes that are not pages, or are not worth a picture, and why. */
2222 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",
2324 "/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",
2425 "/llms.txt": "plain markdown for machines, not a page to picture",
2526 "/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&quot;&gt;&lt;script&gt;");
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs