Public: anyone can read this; the crew can change it.

A pinned forge: one action puts a forge on my cloud, and it changes only when I say #8705

Merged opened this in answer to

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

Merged

torque/forge:torque/8697-a-pinned-forge-one-action-puts-a-forge-o → main 4 commits fork

  • Merged. torque/8697-a-pinned-forge-one-action-puts-a-forge-o was merged into main as b7d7599.

Review

Sign in to review this change.
24 files changed +1250 −1
docs/features/pinned-forge.md added +48
@@ -0,0 +1,48 @@
1 # A pinned forge (#8697)
2
3 A pinned forge is a forge the owner put on their own cloud. It has **no crew** and **no automatic release**. Its version and config change only through one explicit `Update`, recorded with who and when; `Rollback` is one action to the previous version.
4
5 ## Doors (forge admin, admin-scope token)
6
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 - 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 - MCP: `pinned_forge` with `verb` `list`, `deploy`, `update`, `rollback` — the same words and the same checks (`src/actions/pinned.ts`).
10
11 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).
12
13 ## Why it cannot move on its own
14
15 `pinned_forge.version` and `.config` are guarded by a SQLite trigger (migration `20261103090000-pinned-forge`): an UPDATE passes only with a `change_id` that points at a newer `update` or `rollback` row in `pinned_change` whose target is exactly the new version and config. `pinned_change` cannot be updated or deleted. `crew` and `auto_release` are CHECKed to 0. `test/pinned-forge.test.ts` tries each other way and fails if one works. A pinned forge is a row and a droplet, not a repository on this forge, so the crew, the release keeper and upstream following have nothing of it to touch.
16
17 ## Credentials
18
19 The cloud token stays sealed. It is opened for the one call that needs it (a `secret.read` event names the run), sent as an `Authorization` header over HTTPS, and never reaches argv, a row, an event, a request body or the boot script. The boot script holds no secret: the new forge's first visitor claims it. Config refuses names that look like secrets.
20
21 ## Targets: what runs, what it costs, what is refused
22
23 Prices are list prices as last read (USD a month); the owner's own bill is the truth. Source of truth in code: `src/cloud/targets.ts`.
24
25 | Target | Status | What runs | Cost | Refused / caveat |
26 |---|---|---|---|---|
27 | DigitalOcean | **deploys today** | One droplet (`s-1vcpu-2gb`, Ubuntu 24.04) with a 10 GiB block volume as `FORGE_APP_DATA`. An Update deletes the droplet and starts a new one on the same volume, so data is untouched. | ~$12 + ~$1.20 | A token cannot be narrowed below "create droplets and volumes". |
28 | AWS | not written | EC2 `t4g.small` + EBS gp3, same boot script; SigV4 already exists for reads. | ~$12 + ~$0.80 + public IPv4 ~$3.65 | Wants a role scoped to a tag rather than a long-lived key. |
29 | Azure | not written | B1ms VM + managed disk through a service principal on one resource group. | ~$15 + ~$1 + public IP ~$3.65 | Needs a resource group, network and public IP first: three calls more. |
30 | Cloudflare | **refused** | Workers/Durable Objects keep state, but not as files a Bun process opens. Containers run, with a disk wiped on sleep or replace. | Workers Paid from $5 | No volume that survives; the forge refuses rather than lose repositories quietly. |
31 | Vercel | **refused** | Functions and Sandboxes: short-lived, scratch disk. | Pro from $20/seat | No long-running process, no persistent disk. Vercel can host an app a forge ships, not the forge. |
32
33 The cloud facts for Cloudflare and Vercel are from memory of their documentation, last written 2026-11-02; check them before relying on a refusal for a decision beyond this forge.
34
35 ## Prior art
36
37 Open-source "Deploy to" buttons (Heroku's `app.json`, Render's `render.yaml`, DigitalOcean's App Platform button, Railway templates) are a link carrying a repository and a settings file to a provider's sign-in. That is the shape for the button: a link with `app`, `source`, `version` that lands on the Deploy form after one sign-in. The older `deploy/` cloud path and the child-forge path (#4416, #2928, #3785) are the other prior art; neither pins.
38
39 ## Failing half-way
40
41 Each id is recorded the moment it exists. A first deploy that fails before a droplet exists leaves a `failed` row with the volume's id; deploying the same name again reuses that volume. An Update deletes the old droplet and clears its id at once; a 404 on that delete counts as deleted, so after a failed create a Rollback or another Update goes straight to creating. A `failed` forge may be told the version it already has, to finish the replacement. The droplet installs the Bun in `BUN_VERSION` (`src/cloud/droplet.ts`), so the runtime moves only when a pull moves it.
42
43 ## Not done
44
45 - No canary on a real account: #8746.
46 - No `Deploy to…` page or link: #8744.
47 - The droplet's address is not read back, so no URL is shown, no TLS or domain, no health check: #8745.
48 - AWS and Azure drivers.
src/actions/pinned.ts added +147
@@ -0,0 +1,147 @@
1 /**
2 * Pinned forges, as a forge admin reaches them (#8697): `deploy` puts one on
3 * the owner's cloud, `update` is the only way its version or config change,
4 * `rollback` is one action back to the previous version. All three are the
5 * admin's, need the cloud account's writes switched on, and open the sealed
6 * token for the one call that needs it (a `secret.read` event names it). Each
7 * change is recorded with who and when before the cloud is touched; if the
8 * cloud refuses, the record stays and the forge says `failed`, so nothing
9 * moves without a line saying so.
10 */
11
12 import type { App } from "../app.ts";
13 import { gatedFetch } from "../cloud/gate.ts";
14 import { provision, replace } from "../cloud/droplet.ts";
15 import type { Fetch } from "../cloud/providers.ts";
16 import { target as targetOf } from "../cloud/targets.ts";
17 import { getCloudAccount, secretNameOf } from "../core/cloud_account.ts";
18 import {
19 APP, NAME, changesOf, checkConfig, getPinned, insertPinned, listPinned, previousOf,
20 dropPinned, recordChange, setRemote, validSource, validVersion, type Change, type Pinned,
21 } from "../core/pinned.ts";
22 import { resolveSecrets } from "../core/secret_store.ts";
23 import type { User } from "../core/users.ts";
24 import { err, ok, type Result } from "../kernel/result.ts";
25
26 /** Swapped in tests; the real forge always uses `fetch`. */
27 export const net: { fetch: Fetch } = { fetch: (u, i) => fetch(u, i) };
28
29 const admin = (a: User | undefined): Result<User, string> => (a?.isAdmin ? ok(a) : err("not_found"));
30
31 export interface PinnedView extends Pinned {
32 readonly crew: false;
33 readonly autoRelease: false;
34 readonly changes: Change[];
35 }
36
37 const view = (app: App, p: Pinned): PinnedView => ({ ...p, crew: false, autoRelease: false, changes: changesOf(app.db, p.id) });
38
39 export function pinnedForges(app: App, actor: User | undefined): Result<PinnedView[], string> {
40 const who = admin(actor);
41 return who.ok ? ok(listPinned(app.db).map((p) => view(app, p))) : who;
42 }
43
44 /** Open the account's token and run `go` with a gated fetch; the token never leaves this scope. */
45 async function withCloud<T>(app: App, who: User, p: Pinned, purpose: string, go: (f: Fetch, token: string) => Promise<T>): Promise<Result<T, string>> {
46 const account = getCloudAccount(app.db, p.target, p.account);
47 if (!account) return err("No such cloud account.");
48 if (!account.writeAllowed) return err("That cloud account is read-only; allow writes on it first.");
49 const secret = secretNameOf(account.provider, account.name);
50 const token = resolveSecrets(app.db, app.config.dataDir, app.bus, [secret], { actor: who.id, run: `pinned:${purpose}` })[secret];
51 if (token === undefined) return err("No such credential.");
52 try {
53 return ok(await go(gatedFetch(app.db, { provider: p.target, account: p.account }, `pinned:${purpose}`, net.fetch), token));
54 } catch (e) {
55 return err(`The cloud refused: ${e instanceof Error ? e.message.slice(0, 200) : "failed"}`);
56 }
57 }
58
59 export async function deployPinned(
60 app: App,
61 actor: User | undefined,
62 input: { name: string; target: string; account: string; app: string; source: string; version: string; config?: unknown },
63 ): Promise<Result<PinnedView, string>> {
64 const who = admin(actor);
65 if (!who.ok) return who;
66 const t = targetOf(input.target);
67 if (!t) return err("That is not a target the forge knows.");
68 if (t.status === "refused") return err(t.refused);
69 if (t.status !== "first") return err(`${t.label} is not written yet: ${t.refused}`);
70 if (!NAME.test(input.name)) return err("A name is lowercase letters, digits and dashes, starting with a letter.");
71 if (!APP.test(input.app)) return err("Name the app as owner/name.");
72 if (!validSource(input.source)) return err("The source is an https git address.");
73 if (!validVersion(input.version)) return err("A version is a full 40-character commit id or a tag like v1.2.3.");
74 const config = checkConfig(input.config);
75 if (!config.ok) return config;
76 // A first deploy that failed before any droplet exists may be tried again: its row goes,
77 // its volume (if one was made) is kept for the retry, and the new deploy is recorded afresh.
78 const old = getPinned(app.db, input.name);
79 if (old && !(old.state === "failed" && old.remote === "" && old.target === t.id)) return err("A pinned forge has that name already.");
80 if (old) dropPinned(app.db, old.name);
81 const p = insertPinned(app.db, { ...input, target: t.id, config: config.value }, who.value.id);
82 app.bus.append({ kind: "pinned.deployed", actorId: who.value.id, payload: { name: p.name, target: p.target, version: p.version } });
83 if (old?.volume) setRemote(app.db, p.name, { volume: old.volume });
84 const made = await withCloud(app, who.value, p, "deploy", (f, token) =>
85 provision(f, token, getPinned(app.db, p.name)!, {
86 ...(old?.volume ? { have: old.volume } : {}),
87 onVolume: (volume) => setRemote(app.db, p.name, { volume }),
88 }),
89 );
90 if (!made.ok) {
91 setRemote(app.db, p.name, { state: "failed" });
92 return made;
93 }
94 setRemote(app.db, p.name, { ...made.value, state: "running" });
95 return ok(view(app, getPinned(app.db, p.name)!));
96 }
97
98 async function change(
99 app: App,
100 actor: User | undefined,
101 name: string,
102 kind: "update" | "rollback",
103 next: (p: Pinned) => Result<{ version: string; config: Record<string, string> }, string>,
104 ): Promise<Result<PinnedView, string>> {
105 const who = admin(actor);
106 if (!who.ok) return who;
107 const before = getPinned(app.db, name);
108 if (!before) return err("No such pinned forge.");
109 const to = next(before);
110 if (!to.ok) return to;
111 const same = to.value.version === before.version && JSON.stringify(to.value.config) === JSON.stringify(before.config);
112 // A failed forge may be asked again for what it already says, to finish the replacement; nothing new to record.
113 if (same && before.state !== "failed") return err("That is what it runs already.");
114 if (!same) {
115 const c = recordChange(app.db, before, kind, to.value, who.value.id);
116 app.bus.append({ kind: kind === "update" ? "pinned.update" : "pinned.rollback", actorId: who.value.id, payload: { name, from: c.fromVersion, to: c.toVersion } });
117 }
118 const after = getPinned(app.db, name)!;
119 const done = await withCloud(app, who.value, after, kind, (f, token) => replace(f, token, after, { onDeleted: () => setRemote(app.db, name, { remote: "" }) }));
120 if (!done.ok) {
121 setRemote(app.db, name, { state: "failed" });
122 return done;
123 }
124 setRemote(app.db, name, { ...done.value, state: "running" });
125 return ok(view(app, getPinned(app.db, name)!));
126 }
127
128 /** The only door to a new version or config. Either may be left as it is. */
129 export const updatePinned = (
130 app: App,
131 actor: User | undefined,
132 name: string,
133 input: { version?: string | undefined; config?: unknown },
134 ): Promise<Result<PinnedView, string>> =>
135 change(app, actor, name, "update", (p) => {
136 const version = input.version ?? p.version;
137 if (!validVersion(version)) return err("A version is a full 40-character commit id or a tag like v1.2.3.");
138 const config = input.config === undefined ? ok(p.config) : checkConfig(input.config);
139 return config.ok ? ok({ version, config: config.value }) : config;
140 });
141
142 /** One action back to what the latest change replaced. */
143 export const rollbackPinned = (app: App, actor: User | undefined, name: string): Promise<Result<PinnedView, string>> =>
144 change(app, actor, name, "rollback", (p) => {
145 const prev = previousOf(app.db, p);
146 return prev ? ok(prev) : err("There is no previous version: this forge has only ever run one.");
147 });
src/cloud/droplet.ts added +123
@@ -0,0 +1,123 @@
1 /**
2 * DigitalOcean, the first target (#8697): the requests that put a pinned
3 * forge on a droplet with a volume, and replace the droplet on an explicit
4 * Update while the volume — the forge's data — stays. Plain data in, plain
5 * data out: the caller owns the token and the fetch. The token travels in an
6 * Authorization header over HTTPS, never on argv, and the boot script holds no
7 * secret at all: the new forge's first visitor claims it at /first-admin.
8 *
9 * Calls (DigitalOcean API v2): POST /volumes, POST /droplets, DELETE /droplets/{id}.
10 * The caller records each id the moment it exists (`onVolume`, `onDeleted`),
11 * so a failure half-way leaves a row a retry can finish, never an orphan.
12 */
13
14 import type { Fetch } from "./providers.ts";
15 import type { Pinned } from "../core/pinned.ts";
16
17 const API = "https://api.digitalocean.com/v2";
18 export const REGION = "nyc3";
19 export const SIZE = "s-1vcpu-2gb";
20 /** The Bun the droplet installs: it moves only when this line does, in a pull, so the runtime is pinned as the forge is. */
21 export const BUN_VERSION = "1.3.14";
22
23 /** cloud-config for the droplet: Bun, the pinned source at the pinned version, a systemd unit. */
24 export function bootConfig(p: Pick<Pinned, "name" | "source" | "version" | "config">): string {
25 // DigitalOcean mounts a volume at /mnt/<its name, dashes as underscores>.
26 const data = `/mnt/${volumeName(p.name).replace(/-/g, "_")}`;
27 const env = Object.entries({ ...p.config, FORGE_APP_DATA: data })
28 .map(([k, v]) => `${k}=${v}`)
29 .join("\n");
30 // Every interpolated value is validated in core/pinned.ts (version, source, config) or by NAME.
31 return `#cloud-config
32 write_files:
33 - path: /etc/forge/env
34 permissions: "0600"
35 content: ${JSON.stringify(env + "\n")}
36 - path: /etc/systemd/system/forge.service
37 content: |
38 [Unit]
39 Description=forge ${p.name} pinned at ${p.version}
40 After=network-online.target
41 [Service]
42 EnvironmentFile=/etc/forge/env
43 WorkingDirectory=/opt/forge
44 ExecStart=/root/.bun/bin/bun run src/main.ts
45 Restart=on-failure
46 [Install]
47 WantedBy=multi-user.target
48 runcmd:
49 - curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}"
50 - git clone ${p.source} /opt/forge
51 - git -C /opt/forge checkout --detach ${p.version}
52 - systemctl enable --now forge.service
53 `;
54 }
55
56 const headers = (token: string) => ({ authorization: `Bearer ${token}`, "content-type": "application/json" });
57
58 async function call(f: Fetch, token: string, method: string, path: string, body?: unknown): Promise<any> {
59 const res = await f(`${API}${path}`, {
60 method,
61 headers: headers(token),
62 ...(body === undefined ? {} : { body: JSON.stringify(body) }),
63 signal: AbortSignal.timeout(20_000),
64 });
65 if (!res.ok) throw new Error(`api.digitalocean.com answered ${res.status}`);
66 return res.status === 204 ? {} : await res.json();
67 }
68
69 const volumeName = (name: string) => `forge-${name}-data`;
70
71 function createDroplet(f: Fetch, token: string, p: Pinned, volumeId: string) {
72 return call(f, token, "POST", "/droplets", {
73 name: `forge-${p.name}`,
74 region: REGION,
75 size: SIZE,
76 image: "ubuntu-24-04-x64",
77 volumes: [volumeId],
78 user_data: bootConfig(p),
79 tags: ["forge-pinned"],
80 });
81 }
82
83 /**
84 * Volume first, then the droplet that mounts it. A volume a failed earlier try
85 * made (`have`) is reused; a new one is recorded through `onVolume` before the
86 * droplet is asked for. Returns the ids to keep.
87 */
88 export async function provision(
89 f: Fetch, token: string, p: Pinned, hooks: { have?: string; onVolume(id: string): void },
90 ): Promise<{ remote: string; volume: string }> {
91 let volume = hooks.have ?? "";
92 if (volume === "") {
93 const v = await call(f, token, "POST", "/volumes", {
94 name: volumeName(p.name), region: REGION, size_gigabytes: 10, filesystem_type: "ext4",
95 });
96 volume = String(v.volume?.id ?? "");
97 if (!volume) throw new Error("DigitalOcean made no volume");
98 hooks.onVolume(volume);
99 }
100 const d = await createDroplet(f, token, p, volume);
101 return { remote: String(d.droplet?.id ?? ""), volume };
102 }
103
104 /**
105 * An explicit Update or rollback: delete the old droplet, start a new one on
106 * the same volume. A droplet already gone (404, or none recorded after a failed
107 * create) counts as deleted, and `onDeleted` clears the recorded id at once, so
108 * a retry after a failed create does not delete twice.
109 */
110 export async function replace(f: Fetch, token: string, p: Pinned, hooks: { onDeleted(): void }): Promise<{ remote: string }> {
111 if (p.volume === "") throw new Error("this forge has no volume to keep");
112 if (p.remote !== "") {
113 if (!/^\d+$/.test(p.remote)) throw new Error("this forge has no droplet to replace");
114 try {
115 await call(f, token, "DELETE", `/droplets/${p.remote}`);
116 } catch (e) {
117 if (!(e instanceof Error && e.message.endsWith("answered 404"))) throw e;
118 }
119 hooks.onDeleted();
120 }
121 const d = await createDroplet(f, token, p, p.volume);
122 return { remote: String(d.droplet?.id ?? "") };
123 }
src/cloud/targets.ts added +64
@@ -0,0 +1,64 @@
1 /**
2 * Where a forge can be put, and what each place can and cannot hold (#8697).
3 * A forge is a Bun process, SQLite and a disk that outlives the process, so a
4 * target needs a machine and a volume. Prices are list prices for the
5 * smallest size that runs a forge, USD a month, as last read; the page says
6 * so, and the owner's own bill is the truth.
7 */
8
9 export type TargetId = "digitalocean" | "aws" | "azure" | "cloudflare" | "vercel";
10
11 export interface Target {
12 readonly id: TargetId;
13 readonly label: string;
14 /** `first` deploys today; `planned` has a machine and a volume but no driver yet; `refused` cannot hold a forge. */
15 readonly status: "first" | "planned" | "refused";
16 readonly runs: string;
17 readonly cost: string;
18 readonly refused: string;
19 }
20
21 export const TARGETS: readonly Target[] = [
22 {
23 id: "digitalocean",
24 label: "DigitalOcean",
25 status: "first",
26 runs: "One droplet (Ubuntu, Bun from the pinned source) with one block volume mounted as FORGE_APP_DATA. Replacing the droplet keeps the volume, so a version change never touches the data.",
27 cost: "about $12 (s-1vcpu-2gb) + $1.20 (10 GiB volume)",
28 refused: "Nothing the forge needs is refused; the token must be able to create droplets and volumes, which DigitalOcean cannot narrow further.",
29 },
30 {
31 id: "aws",
32 label: "AWS",
33 status: "planned",
34 runs: "One EC2 instance (t4g.small) with one EBS gp3 volume, same boot script. Needs SigV4 signing for RunInstances, which providers.ts already does for reads.",
35 cost: "about $12 + $0.80 (10 GiB gp3) + a public IPv4 address, about $3.65",
36 refused: "A long-lived access key with EC2 write is the broadest credential of the three; the driver will ask for a role scoped to one tag, and is not written yet.",
37 },
38 {
39 id: "azure",
40 label: "Azure",
41 status: "planned",
42 runs: "One VM (B1ms) with one managed disk, through a service principal with Contributor on one resource group.",
43 cost: "about $15 + $1 (10 GiB standard SSD) + a public IP, about $3.65",
44 refused: "Not written yet: it needs a resource group, a network and a public IP created first, so it is three calls more than the droplet.",
45 },
46 {
47 id: "cloudflare",
48 label: "Cloudflare",
49 status: "refused",
50 runs: "Workers and Durable Objects hold state, but not as a file system a Bun process can open. Cloudflare Containers run a container, with a disk that is wiped when the container sleeps or is replaced.",
51 cost: "Workers Paid, $5 a month minimum, plus container time",
52 refused: "A forge keeps bare git repositories and SQLite on a disk that survives restarts. Cloudflare offers no such volume to a container, so the forge refuses to put one there rather than lose data quietly.",
53 },
54 {
55 id: "vercel",
56 label: "Vercel",
57 status: "refused",
58 runs: "Functions and Sandboxes: short-lived, one request or one task, with a scratch disk that does not persist.",
59 cost: "Pro from $20 a seat a month",
60 refused: "No long-running process and no persistent disk, so a forge cannot live there. Vercel can host an app a forge ships, which is a different thing.",
61 },
62 ];
63
64 export const target = (id: string): Target | undefined => TARGETS.find((t) => t.id === id);
src/contract/openapi/routes/getApiV1AdminPinned.ts added +12
@@ -0,0 +1,12 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: "/api/v1/admin/pinned",
6 summary: "The forge's pinned forges with each one's recorded changes, and the targets with what each can and cannot host; a forge administrator's",
7 auth: true,
8 refusals: {
9 not_found: "only a forge administrator sees this route",
10 refused: "the request was understood and refused; the message says why (422)",
11 },
12 };
src/contract/openapi/routes/index.ts +4
@@ -35,6 +35,7 @@ export { route as getApiV1AdminDrills } from "./getApiV1AdminDrills.ts";
3535 export { route as getApiV1AdminInvites } from "./getApiV1AdminInvites.ts";
3636 export { route as getApiV1AdminLimits } from "./getApiV1AdminLimits.ts";
3737 export { route as getApiV1AdminPeople } from "./getApiV1AdminPeople.ts";
38 export { route as getApiV1AdminPinned } from "./getApiV1AdminPinned.ts";
3839 export { route as getApiV1AdminProfile } from "./getApiV1AdminProfile.ts";
3940 export { route as getApiV1AdminQuotas } from "./getApiV1AdminQuotas.ts";
4041 export { route as getApiV1AdminSettings } from "./getApiV1AdminSettings.ts";
@@ -193,6 +194,9 @@ export { route as postApiV1AdminAccountsUsernameSuspend } from "./postApiV1Admin
193194 export { route as postApiV1AdminAccountsUsernameUnsuspend } from "./postApiV1AdminAccountsUsernameUnsuspend.ts";
194195 export { route as postApiV1AdminAccountsUsernameVerify } from "./postApiV1AdminAccountsUsernameVerify.ts";
195196 export { route as postApiV1AdminInvites } from "./postApiV1AdminInvites.ts";
197 export { route as postApiV1AdminPinned } from "./postApiV1AdminPinned.ts";
198 export { route as postApiV1AdminPinnedNameRollback } from "./postApiV1AdminPinnedNameRollback.ts";
199 export { route as postApiV1AdminPinnedNameUpdate } from "./postApiV1AdminPinnedNameUpdate.ts";
196200 export { route as postApiV1AdminReleasesShaRollback } from "./postApiV1AdminReleasesShaRollback.ts";
197201 export { route as postApiV1Agents } from "./postApiV1Agents.ts";
198202 export { route as postApiV1Apps } from "./postApiV1Apps.ts";
src/contract/openapi/routes/postApiV1AdminPinned.ts added +14
@@ -0,0 +1,14 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "post",
5 path: "/api/v1/admin/pinned",
6 summary: "Put a pinned forge on the owner's cloud: no crew, no automatic release; needs an admin-scope token and a cloud account with writes allowed",
7 auth: true,
8 status: 201,
9 body: { name: "string", target: "string", account: "string", app: "string", source: "string", version: "string", config: "object" },
10 refusals: {
11 not_found: "only a forge administrator sees this route",
12 refused: "the request was understood and refused; the message says why (422)",
13 },
14 };
src/contract/openapi/routes/postApiV1AdminPinnedNameRollback.ts added +12
@@ -0,0 +1,12 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "post",
5 path: "/api/v1/admin/pinned/{name}/rollback",
6 summary: "One action back to the version the latest change replaced; recorded like any change",
7 auth: true,
8 refusals: {
9 not_found: "only a forge administrator sees this route",
10 refused: "the request was understood and refused; the message says why (422)",
11 },
12 };
src/contract/openapi/routes/postApiV1AdminPinnedNameUpdate.ts added +13
@@ -0,0 +1,13 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "post",
5 path: "/api/v1/admin/pinned/{name}/update",
6 summary: "The only way a pinned forge changes version or config; recorded with who and when",
7 auth: true,
8 body: { version: "string", config: "object" },
9 refusals: {
10 not_found: "only a forge administrator sees this route",
11 refused: "the request was understood and refused; the message says why (422)",
12 },
13 };
src/core/event_audience.ts +2
@@ -176,6 +176,8 @@ export function classifiedAudience(kind: string): EventAudience | undefined {
176176 kind === "forge.upstream" ||
177177 kind === "forge.updates_taken" ||
178178 kind === "cloud.write_allowed" ||
179 // A pinned forge's deploy, update and rollback name the owner's cloud (#8697).
180 kind.startsWith("pinned.") ||
179181 // Which of a person's connected accounts a mirror syncs through is too.
180182 kind.startsWith("origin.") ||
181183 kind === "repo.settings_updated" ||
src/core/migration-pins/20261103090000-pinned-forge.pin added +1
@@ -0,0 +1,1 @@
1 b0bf868cfb029ac5288cae0d3f2a0e01db2d98774f1072f220e6ef2c295df7a6
src/core/migrations/20261103090000-pinned-forge.ts added +50
@@ -0,0 +1,50 @@
1 // A pinned forge (#8697): a forge the owner put on their own cloud, which has
2 // no crew and no automatic release. Its version and config are changed only by
3 // a row in pinned_change (who, when, from, to); the trigger refuses any other
4 // UPDATE of them, and the CHECKs keep crew and auto_release at 0. Additive.
5 export default `
6 CREATE TABLE pinned_forge (
7 id TEXT PRIMARY KEY,
8 name TEXT NOT NULL UNIQUE,
9 target TEXT NOT NULL,
10 account TEXT NOT NULL,
11 app TEXT NOT NULL,
12 source TEXT NOT NULL,
13 version TEXT NOT NULL,
14 config TEXT NOT NULL,
15 crew INTEGER NOT NULL DEFAULT 0 CHECK (crew = 0),
16 auto_release INTEGER NOT NULL DEFAULT 0 CHECK (auto_release = 0),
17 change_id INTEGER NOT NULL DEFAULT 0,
18 remote TEXT NOT NULL DEFAULT '',
19 volume TEXT NOT NULL DEFAULT '',
20 state TEXT NOT NULL DEFAULT 'new',
21 created_at TEXT NOT NULL
22 );
23 CREATE TABLE pinned_change (
24 id INTEGER PRIMARY KEY,
25 pinned_id TEXT NOT NULL,
26 kind TEXT NOT NULL CHECK (kind IN ('deploy','update','rollback')),
27 from_version TEXT,
28 to_version TEXT NOT NULL,
29 from_config TEXT,
30 to_config TEXT NOT NULL,
31 by TEXT NOT NULL,
32 at TEXT NOT NULL
33 );
34 CREATE INDEX pinned_change_forge ON pinned_change (pinned_id, id);
35 CREATE TRIGGER pinned_change_kept BEFORE UPDATE ON pinned_change
36 BEGIN SELECT RAISE(ABORT, 'pinned: a recorded change is history'); END;
37 CREATE TRIGGER pinned_change_kept_del BEFORE DELETE ON pinned_change
38 BEGIN SELECT RAISE(ABORT, 'pinned: a recorded change is history'); END;
39 CREATE TRIGGER pinned_only_by_update
40 BEFORE UPDATE OF version, config ON pinned_forge
41 WHEN NEW.version IS NOT OLD.version OR NEW.config IS NOT OLD.config
42 BEGIN
43 SELECT RAISE(ABORT, 'pinned: version and config change only through an explicit Update')
44 WHERE NEW.change_id <= OLD.change_id OR NOT EXISTS (
45 SELECT 1 FROM pinned_change c
46 WHERE c.id = NEW.change_id AND c.pinned_id = OLD.id
47 AND c.kind IN ('update','rollback')
48 AND c.to_version = NEW.version AND c.to_config = NEW.config);
49 END;
50 `;
src/core/pinned.ts added +165
@@ -0,0 +1,165 @@
1 /**
2 * Pinned forges (#8697): rows, and the one way their version and config move.
3 * `recordChange` is the only writer of either; the schema trigger refuses an
4 * UPDATE that skips it (migration 20261103090000). A pinned forge has no crew
5 * and no automatic release, and the CHECKs keep it so.
6 */
7
8 import type { Database } from "bun:sqlite";
9 import { id as newId } from "../kernel/id.ts";
10 import { err, ok, type Result } from "../kernel/result.ts";
11
12 export interface Pinned {
13 readonly id: string;
14 readonly name: string;
15 readonly target: string;
16 readonly account: string;
17 readonly app: string;
18 readonly source: string;
19 readonly version: string;
20 readonly config: Record<string, string>;
21 readonly remote: string;
22 readonly volume: string;
23 readonly state: string;
24 readonly createdAt: string;
25 }
26
27 export interface Change {
28 readonly id: number;
29 readonly kind: "deploy" | "update" | "rollback";
30 readonly fromVersion: string | null;
31 readonly toVersion: string;
32 readonly by: string;
33 readonly at: string;
34 }
35
36 export const NAME = /^[a-z][a-z0-9-]{0,39}$/;
37 /** A full commit id or a release tag such as v1.4.2 — nothing a shell or git could read as a flag. */
38 export const VERSION = /^(?:[0-9a-f]{40}|v\d{1,4}\.\d{1,4}\.\d{1,4})$/;
39 export const APP = /^[a-z0-9][a-z0-9_-]{0,38}\/[a-z0-9][a-z0-9_.-]{0,99}$/;
40 const SOURCE = /^https:\/\/[a-z0-9][a-z0-9.-]{0,100}(?:\/[A-Za-z0-9._~-]{1,100}){1,4}$/;
41 const KEY = /^[A-Z][A-Z0-9_]{0,63}$/;
42 // A value is written to a systemd EnvironmentFile on the machine: no quote,
43 // backslash, dollar, backtick or newline can reach it.
44 const VALUE = /^[A-Za-z0-9 _.,:\/@+=-]{0,512}$/;
45 const SECRETISH = /SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE|CREDENTIAL|API_?KEY|ACCESS_?KEY/;
46
47 export function validVersion(v: string): boolean {
48 return VERSION.test(v);
49 }
50
51 export function validSource(s: string): boolean {
52 return SOURCE.test(s) && !s.includes("..");
53 }
54
55 /** Config is plain, non-secret settings; a secret-looking name is refused so none lands in a row. */
56 export function checkConfig(raw: unknown): Result<Record<string, string>, string> {
57 if (raw === undefined) return ok({});
58 if (typeof raw !== "object" || raw === null || Array.isArray(raw))
59 return err("config is an object of NAME: value");
60 const out: Record<string, string> = {};
61 const entries = Object.entries(raw);
62 if (entries.length > 40) return err("config holds at most 40 settings");
63 for (const [k, v] of entries) {
64 if (!KEY.test(k)) return err(`config name ${k.slice(0, 20)} is capitals, digits and underscores`);
65 if (SECRETISH.test(k)) return err(`${k} looks like a secret; secrets are sealed in cloud accounts, never in config`);
66 if (typeof v !== "string" || !VALUE.test(v)) return err(`config ${k} is plain text of letters, digits and . , : / @ + = - _`);
67 out[k] = v;
68 }
69 return ok(Object.fromEntries(Object.entries(out).sort(([a], [b]) => (a < b ? -1 : 1))));
70 }
71
72 interface Row {
73 id: string; name: string; target: string; account: string; app: string; source: string;
74 version: string; config: string; remote: string; volume: string; state: string; created_at: string;
75 }
76
77 const toPinned = (r: Row): Pinned => ({
78 id: r.id, name: r.name, target: r.target, account: r.account, app: r.app, source: r.source,
79 version: r.version, config: JSON.parse(r.config) as Record<string, string>,
80 remote: r.remote, volume: r.volume, state: r.state, createdAt: r.created_at,
81 });
82
83 export const listPinned = (db: Database): Pinned[] =>
84 db.query<Row, []>("SELECT * FROM pinned_forge ORDER BY created_at, id").all().map(toPinned);
85
86 export function getPinned(db: Database, name: string): Pinned | undefined {
87 const r = db.query<Row, [string]>("SELECT * FROM pinned_forge WHERE name = ?1").get(name);
88 return r ? toPinned(r) : undefined;
89 }
90
91 export function changesOf(db: Database, pinnedId: string): Change[] {
92 return db
93 .query<{ id: number; kind: Change["kind"]; from_version: string | null; to_version: string; by: string; at: string }, [string]>(
94 "SELECT id, kind, from_version, to_version, by, at FROM pinned_change WHERE pinned_id = ?1 ORDER BY id DESC",
95 )
96 .all(pinnedId)
97 .map((c) => ({ id: c.id, kind: c.kind, fromVersion: c.from_version, toVersion: c.to_version, by: c.by, at: c.at }));
98 }
99
100 export function insertPinned(
101 db: Database,
102 p: { name: string; target: string; account: string; app: string; source: string; version: string; config: Record<string, string> },
103 by: string,
104 ): Pinned {
105 const at = new Date().toISOString();
106 const pid = newId();
107 const config = JSON.stringify(p.config);
108 db.transaction(() => {
109 const c = db
110 .query("INSERT INTO pinned_change (pinned_id, kind, from_version, to_version, from_config, to_config, by, at) VALUES (?1, 'deploy', NULL, ?2, NULL, ?3, ?4, ?5)")
111 .run(pid, p.version, config, by, at);
112 db.query(
113 "INSERT INTO pinned_forge (id, name, target, account, app, source, version, config, change_id, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
114 ).run(pid, p.name, p.target, p.account, p.app, p.source, p.version, config, Number(c.lastInsertRowid), at);
115 })();
116 return getPinned(db, p.name)!;
117 }
118
119 /**
120 * The one explicit change: write who, when, from and to, then move the pin.
121 * Returns the stored change. Rollback's target is the `from` of the latest
122 * change, so it is always one action to the previous version.
123 */
124 export function recordChange(
125 db: Database,
126 forge: Pinned,
127 kind: "update" | "rollback",
128 to: { version: string; config: Record<string, string> },
129 by: string,
130 ): Change {
131 const at = new Date().toISOString();
132 const toConfig = JSON.stringify(to.config);
133 db.transaction(() => {
134 const c = db
135 .query("INSERT INTO pinned_change (pinned_id, kind, from_version, to_version, from_config, to_config, by, at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)")
136 .run(forge.id, kind, forge.version, to.version, JSON.stringify(forge.config), toConfig, by, at);
137 db.query("UPDATE pinned_forge SET version = ?1, config = ?2, change_id = ?3 WHERE id = ?4").run(
138 to.version, toConfig, Number(c.lastInsertRowid), forge.id,
139 );
140 })();
141 return changesOf(db, forge.id)[0]!;
142 }
143
144 /** What a rollback would restore: the latest change's `from`, if it had one. */
145 export function previousOf(db: Database, forge: Pinned): { version: string; config: Record<string, string> } | undefined {
146 const r = db
147 .query<{ from_version: string | null; from_config: string | null }, [string]>(
148 "SELECT from_version, from_config FROM pinned_change WHERE pinned_id = ?1 ORDER BY id DESC LIMIT 1",
149 )
150 .get(forge.id);
151 return r?.from_version != null && r.from_config != null
152 ? { version: r.from_version, config: JSON.parse(r.from_config) as Record<string, string> }
153 : undefined;
154 }
155
156 /** Machine facts the target reports back; they never touch version or config. */
157 export function setRemote(db: Database, name: string, f: { remote?: string; volume?: string; state?: string }): void {
158 db.query("UPDATE pinned_forge SET remote = coalesce(?1, remote), volume = coalesce(?2, volume), state = coalesce(?3, state) WHERE name = ?4")
159 .run(f.remote ?? null, f.volume ?? null, f.state ?? null, name);
160 }
161
162 /** The row of a deploy that never reached a droplet, so its name can be tried again; its recorded change stays as history. */
163 export function dropPinned(db: Database, name: string): void {
164 db.query("DELETE FROM pinned_forge WHERE name = ?1 AND remote = '' AND state = 'failed'").run(name);
165 }
src/crew/conversation_confirm.ts +1
@@ -89,6 +89,7 @@ export const ASKING: Readonly<Record<string, Asking>> = {
8989 converge: { does: "have a fork's crew merge the parent's updates" },
9090 origins: { does: "sync an origin or replace the internal hosts one may live on", asks: not("action", "list", "repos") },
9191 cloud_account: { does: "change a cloud account" },
92 pinned_forge: { does: "deploy, update or roll back a pinned forge on the owner's cloud", asks: not("verb", "list") },
9293 outsiders: { does: "let non-writers direct an app's crew, or stop them" },
9394 seal: { does: "seal or unseal an issue or pull" },
9495 migrate: { does: "bring a repository in from a public address" },
src/http/api/admin.ts +42
@@ -14,6 +14,8 @@ import { changeCapacity, listCapacity } from "../../actions/capacity.ts";
1414 import { inviteLink, list as listInviteLinks, mint as mintInviteLink, revoke as revokeInviteLink } from "../../actions/invites.ts";
1515 import { changeLimit, listLimits } from "../../actions/limits.ts";
1616 import { setAccountCeilings, setAccountGrant } from "../../actions/model_grant.ts";
17 import { deployPinned, pinnedForges, rollbackPinned, updatePinned } from "../../actions/pinned.ts";
18 import { TARGETS } from "../../cloud/targets.ts";
1719 import { changeQuotas, readQuotas } from "../../actions/quotas.ts";
1820 import { setRole } from "../../actions/roles.ts";
1921 import { SETTING_WORDS } from "../../actions/setting_words.ts";
@@ -37,6 +39,46 @@ export const accountAdmin = (ctx: Ctx, scope: "ro" | "admin"): Response | undefi
3739 ctx.auth.user?.isAdmin === true ? requireScope(ctx, scope) : notFound();
3840
3941 export function adminRoutes(router: Router<Handler>): void {
42 // ---- pinned forges (#8697): one deploy, and a change only by Update ----
43 const pinnedFail = (error: string): Response =>
44 error === "not_found" || error === "No such pinned forge." ? notFound() : apiError("refused", 422, error);
45
46 router.add("GET", "/api/v1/admin/pinned", (ctx) => {
47 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
48 const denied = accountAdmin(ctx, "ro");
49 if (denied) return denied;
50 const seen = pinnedForges(ctx.app, ctx.auth.user);
51 return seen.ok ? json({ items: seen.value, targets: TARGETS, next_cursor: null }) : notFound();
52 });
53
54 router.add("POST", "/api/v1/admin/pinned", async (ctx) => {
55 const denied = accountAdmin(ctx, "admin");
56 if (denied) return denied;
57 const i = await body(ctx.request);
58 const made = await deployPinned(ctx.app, ctx.auth.user, {
59 name: str(i.name), target: str(i.target), account: str(i.account), app: str(i.app),
60 source: str(i.source), version: str(i.version), config: i.config,
61 });
62 return made.ok ? json(made.value, 201) : pinnedFail(made.error);
63 });
64
65 router.add("POST", "/api/v1/admin/pinned/:name/update", async (ctx) => {
66 const denied = accountAdmin(ctx, "admin");
67 if (denied) return denied;
68 const i = await body(ctx.request);
69 const done = await updatePinned(ctx.app, ctx.auth.user, ctx.params.name!, {
70 version: i.version === undefined ? undefined : str(i.version), config: i.config,
71 });
72 return done.ok ? json(done.value) : pinnedFail(done.error);
73 });
74
75 router.add("POST", "/api/v1/admin/pinned/:name/rollback", async (ctx) => {
76 const denied = accountAdmin(ctx, "admin");
77 if (denied) return denied;
78 const done = await rollbackPinned(ctx.app, ctx.auth.user, ctx.params.name!);
79 return done.ok ? json(done.value) : pinnedFail(done.error);
80 });
81
4082 // ---- accounts: the owner's view of who joined (#660) ----
4183 const accountError = (error: string): Response =>
4284 error === "not_found"
src/http/mcp/tools/index.ts +1
@@ -98,6 +98,7 @@ export { tool as origins } from "./origins.ts";
9898 export { tool as outsiders } from "./outsiders.ts";
9999 export { tool as parent_unlink } from "./parent_unlink.ts";
100100 export { tool as person } from "./person.ts";
101 export { tool as pinned_forge } from "./pinned_forge.ts";
101102 export { tool as pins } from "./pins.ts";
102103 export { tool as plan } from "./plan.ts";
103104 export { tool as preview } from "./preview.ts";
src/http/mcp/tools/pinned_forge.ts added +54
@@ -0,0 +1,54 @@
1 import { deployPinned, pinnedForges, rollbackPinned, updatePinned } from "../../../actions/pinned.ts";
2 import { TARGETS } from "../../../cloud/targets.ts";
3 import { scopeAllows } from "../../../core/tokens.ts";
4 import { good, bad, type Tool, str } from "../shared.ts";
5
6 export const tool: Tool = {
7 name: "pinned_forge",
8 doc: "`{ verb, name?, target?, account?, app?, source?, version?, config? }`: a pinned forge on the owner's cloud has no crew and no automatic release. `list` shows them with every recorded change and the targets (what runs, what it costs, what is refused); `deploy` puts one on `target` with a cloud `account` (writes allowed) serving `app` owner/name from `source` at `version`; `update` is the only way its `version` or `config` change; `rollback` goes back one. A forge admin with an admin-scope token",
9 description: "Pinned forges on your cloud: list, deploy, update, rollback (admin).",
10 inputSchema: {
11 type: "object",
12 properties: {
13 verb: { type: "string", enum: ["list", "deploy", "update", "rollback"] },
14 name: { type: "string", maxLength: 40 },
15 target: { type: "string", enum: TARGETS.map((t) => t.id) },
16 account: { type: "string", maxLength: 32 },
17 app: { type: "string", maxLength: 140 },
18 source: { type: "string", maxLength: 300 },
19 version: { type: "string", maxLength: 40, description: "A 40-character commit id or a tag like v1.2.3." },
20 config: { type: "object", description: "Plain NAME: value settings; never a secret." },
21 },
22 required: ["verb"],
23 },
24 // Marked a write so a `ro` token never lists the owner's cloud arrangements.
25 writes: true,
26 async run(ctx, user, args) {
27 if (!user.isAdmin) return bad("not_found", "no such tool here");
28 const verb = str(args["verb"]);
29 if (verb === "list") {
30 const seen = pinnedForges(ctx.app, user);
31 return seen.ok ? good({ items: seen.value, targets: TARGETS }) : bad("not_found", "no such tool here");
32 }
33 if (!scopeAllows(ctx.auth.scope ?? "ro", "admin"))
34 return bad("forbidden", "changing a pinned forge needs an admin-scope token");
35 const name = str(args["name"]);
36 const done =
37 verb === "deploy"
38 ? await deployPinned(ctx.app, user, {
39 name, target: str(args["target"]), account: str(args["account"]), app: str(args["app"]),
40 source: str(args["source"]), version: str(args["version"]), config: args["config"],
41 })
42 : verb === "update"
43 ? await updatePinned(ctx.app, user, name, {
44 version: args["version"] === undefined ? undefined : str(args["version"]),
45 config: args["config"],
46 })
47 : verb === "rollback"
48 ? await rollbackPinned(ctx.app, user, name)
49 : undefined;
50 if (done === undefined) return bad("bad_verb", "verb is list, deploy, update or rollback");
51 if (!done.ok) return bad("refused", done.error);
52 return good({ says: `${verb} ${name}: now ${done.value.version}, ${done.value.state}.`, forge: done.value });
53 },
54 };
src/tui/screens/entries.ts +2
@@ -29,6 +29,7 @@ import { entry as orgGrants } from "./orggrants.ts";
2929 import { entry as orgTeams } from "./orgteams.ts";
3030 import { entry as owner } from "./owner.ts";
3131 import { entry as people } from "./people.ts";
32 import { entry as pinned } from "./pinned.ts";
3233 import { entry as profile } from "./profile.ts";
3334 import { entry as quotas } from "./quotas.ts";
3435 import { entry as resilience } from "./resilience.ts";
@@ -82,6 +83,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
8283 bulk,
8384 subscriptions,
8485 cloud,
86 pinned,
8587 connections,
8688 domains,
8789 feed,
src/tui/screens/pinned.ts added +112
@@ -0,0 +1,112 @@
1 /**
2 * Pinned forges in the terminal, a forge admin's (#8697): each forge with its
3 * version, state and last changes, as the API lists them. `d` deploys, `u`
4 * updates (the only way its version or config move), `r` rolls back to the
5 * previous version, each through `actions/pinned.ts`, so the checks, the
6 * record of who and when and the events the web screens follow are the API's.
7 * Anyone else sees nothing and does nothing.
8 */
9
10 import { deployPinned, pinnedForges, rollbackPinned, updatePinned } from "../../actions/pinned.ts";
11 import type { Ctx, Screen } from "../app/app.ts";
12 import { binding } from "../app/keys.ts";
13 import { style } from "../term/style.ts";
14 import { truncate } from "../term/width.ts";
15 import type { Session } from "../session.ts";
16 import { fill, len, split } from "../ui/layout.ts";
17 import { ListState, drawList, live, promptScreen, type ListRow } from "./common.ts";
18 import type { PaletteEntry } from "./entries.ts";
19
20 const short = (v: string): string => (v.length === 40 ? v.slice(0, 10) : v);
21
22 export function pinnedScreen(f: Session, ctx: Ctx): Screen {
23 const state = new ListState();
24 let note = "";
25 const read = () => pinnedForges(f.app, f.user);
26 const forges = () => {
27 const r = read();
28 return r.ok ? r.value : [];
29 };
30 const say = (text: string): void => {
31 note = text;
32 ctx.invalidate();
33 };
34 const settle = async (r: Promise<{ ok: true; value: { name: string; version: string } } | { ok: false; error: string }>, what: string): Promise<void> => {
35 const made = await r;
36 say(made.ok ? `${what} ${made.value.name} at ${short(made.value.version)}.` : made.error);
37 };
38
39 const deploy = (parent: Screen): Screen =>
40 promptScreen(ctx, parent, {
41 title: "Deploy a pinned forge",
42 label: "name provider/account owner/app https-source version",
43 placeholder: "acme digitalocean/main alice/shop https://host/alice/shop v1.0.0",
44 onSubmit: (raw) => {
45 const [name = "", cloud = "", app = "", source = "", version = "", ...rest] = raw.trim().split(/\s+/);
46 const [target = "", account = "", ...more] = cloud.split("/");
47 if (rest.length > 0 || more.length > 0 || version === "" || account === "") return "Write: name provider/account owner/app source version.";
48 say(`Deploying ${name}…`);
49 return settle(deployPinned(f.app, f.user, { name, target, account, app, source, version }), "Deployed");
50 },
51 });
52
53 const screen: Screen = {
54 title: "Pinned forges",
55 keys: [binding(["j", "k"], "move cursor"), binding(["d"], "deploy"), binding(["u"], "update"), binding(["r"], "roll back")],
56 async load(signal) {
57 live(f, signal, { kinds: ["pinned.deployed", "pinned.update", "pinned.rollback"] }, () => ctx.invalidate());
58 },
59 render(area, s) {
60 const c = ctx.theme.c;
61 const r = read();
62 const [head, list] = split(area, "v", [len(1), fill(1)]);
63 const selected = forges()[state.index];
64 const text = note !== "" ? note : !r.ok ? "Pinned forges are a forge admin's." : selected ? `${selected.app} from ${selected.source}` : "No pinned forge. Press d to deploy one.";
65 s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
66 const rows: ListRow[] = forges().map((p) => ({
67 title: p.name,
68 ...(p.state === "failed" ? { tone: "danger" as const } : {}),
69 secondary: `${p.target} · ${short(p.version)} · ${p.state}`,
70 meta: p.changes[0] ? `${p.changes[0].kind} ${p.changes[0].at.slice(0, 16).replace("T", " ")}` : "",
71 }));
72 drawList(s, list!, c, state, rows);
73 },
74 key(e) {
75 if (state.key(e, forges().length)) return true;
76 if (e.ctrl || e.alt || !read().ok) return false;
77 if (e.key === "d") {
78 ctx.push(deploy(screen));
79 return true;
80 }
81 const p = forges()[state.index];
82 if (p === undefined) return false;
83 if (e.key === "r") {
84 say(`Rolling ${p.name} back…`);
85 void settle(rollbackPinned(f.app, f.user, p.name), "Rolled back");
86 } else if (e.key === "u")
87 ctx.push(
88 promptScreen(ctx, screen, {
89 title: `Update ${p.name}`,
90 label: "the new version: a 40-character commit id or a tag",
91 onSubmit: (version) => {
92 say(`Updating ${p.name}…`);
93 return settle(updatePinned(f.app, f.user, p.name, { version: version.trim() }), "Updated");
94 },
95 }),
96 );
97 else return false;
98 return true;
99 },
100 };
101 return screen;
102 }
103
104 /** Pinned forges, as the API and the MCP have them: a forge admin (#8697). */
105 export const entry: PaletteEntry = {
106 id: "pinned",
107 title: "Pinned forges",
108 hint: "deploy, update and roll back a forge on your cloud",
109 after: ["cloud"],
110 offered: (session) => session.user?.isAdmin === true,
111 run: (session, ctx) => ctx.push(pinnedScreen(session, ctx)),
112 };
test/conversation-confirm.test.ts +1 −1
@@ -137,7 +137,7 @@ describe("the one list of what asks first", () => {
137137 expect(listed || NOT_ASKED.has(t.name), `${t.name} writes: ask first (ASKING) or name it in NOT_ASKED`).toBe(true);
138138 expect(listed && NOT_ASKED.has(t.name), `${t.name} is on both lists`).toBe(false);
139139 }
140 for (const name of ["land_past_gate", "share", "invites", "egress_grants", "sever", "parent_unlink", "quotas", "spawn", "app_deploy", "stop", "converge", "origins", "cloud_account", "outsiders", "seal", "migrate"])
140 for (const name of ["land_past_gate", "share", "invites", "egress_grants", "sever", "parent_unlink", "quotas", "spawn", "app_deploy", "stop", "converge", "origins", "cloud_account", "pinned_forge", "outsiders", "seal", "migrate"])
141141 expect(ASKING[name], name).toBeDefined();
142142 });
143143
test/mcp.test.ts +1
@@ -303,6 +303,7 @@ describe("the forge over MCP", () => {
303303 "egress_grants",
304304 "explore",
305305 "person",
306 "pinned_forge",
306307 "search",
307308 "feed",
308309 "feed_chips",
test/palette-roster.test.ts +2
@@ -110,6 +110,7 @@ test("a site-admin person is offered the whole roster", () => {
110110 ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"],
111111 ["archived", "Archived repositories", "put away, not gone: open one or wake it"],
112112 ["cloud", "Cloud accounts", "provider tokens, what they are used for, today's calls"],
113 ["pinned", "Pinned forges", "deploy, update and roll back a forge on your cloud"],
113114 ["origins", "Connected origins", "the hosts your mirrors come from"],
114115 ["domains", "Failure domains", "failure domains and their last drill"],
115116 ["feed", "Feed", "what happened, ranked; r replies on a post about an issue or pull, l likes"],
@@ -164,6 +165,7 @@ test("a site-admin agent is offered the roster without the person-only entries",
164165 ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"],
165166 ["archived", "Archived repositories", "put away, not gone: open one or wake it"],
166167 ["cloud", "Cloud accounts", "provider tokens, what they are used for, today's calls"],
168 ["pinned", "Pinned forges", "deploy, update and roll back a forge on your cloud"],
167169 ["feed", "Feed", "what happened, ranked; r replies on a post about an issue or pull, l likes"],
168170 ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"],
169171 ["forges", "Forges you sent", "each forge sent, claimed or not, and its apps"],
test/pinned-forge-recovery.test.ts added +153
@@ -0,0 +1,153 @@
1 /**
2 * A pinned forge that fails half-way is recoverable (#8697): a failed create
3 * after the delete, a failed first deploy, and a volume made before the droplet
4 * all leave a row a retry finishes. The terminal door reaches the same actions.
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 { addAccount, allowWrite, net as cloudNet } from "../src/actions/cloud.ts";
12 import { deployPinned, net, rollbackPinned, updatePinned } from "../src/actions/pinned.ts";
13 import { BUN_VERSION, bootConfig } from "../src/cloud/droplet.ts";
14 import { getPinned } from "../src/core/pinned.ts";
15 import { createUser, getUserByName } from "../src/core/users.ts";
16 import { Session } from "../src/tui/session.ts";
17 import { entry, pinnedScreen } from "../src/tui/screens/pinned.ts";
18 import { quietLogs } from "./helpers/globals.ts";
19
20 quietLogs();
21
22 const V1 = "a".repeat(40);
23 const V2 = "b".repeat(40);
24 const SOURCE = "https://forge.example.org/trevato/forge";
25
26 describe("pinned forge recovery", () => {
27 let dataDir = "";
28 let app: App;
29 const calls: string[] = [];
30 /** Paths whose next call fails, with the status. */
31 const fail = new Map<string, number>();
32 let deleted = new Set<string>();
33
34 const admin = () => getUserByName(app.db, "alice")!;
35 const input = (over: Record<string, unknown> = {}) => ({
36 name: "acme", target: "digitalocean", account: "main", app: "alice/shop", source: SOURCE, version: V1, config: {}, ...over,
37 });
38
39 beforeAll(async () => {
40 dataDir = await mkdtemp(join(tmpdir(), "forge-pinned-rec-"));
41 app = createApp({ dataDir, port: 0 });
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 cloudNet.fetch = async () => new Response(JSON.stringify({ account: { email: "[email protected]", status: "active" } }), { status: 200 });
47 const added = await addAccount(app, admin(), "digitalocean", "main", "dop_v1_9Zq4TxLm7Kp2Vb8nWcR5");
48 if (!added.ok) throw new Error(String(added.error));
49 allowWrite(app, admin(), "digitalocean", "main", true);
50 net.fetch = async (url, init) => {
51 const path = new URL(url).pathname;
52 const method = init?.method ?? "GET";
53 calls.push(`${method} ${path}`);
54 const key = `${method} ${path.replace(/\/\d+$/, "")}`;
55 const status = fail.get(key);
56 if (status !== undefined) { fail.delete(key); return new Response("{}", { status }); }
57 if (path === "/v2/volumes") return new Response(JSON.stringify({ volume: { id: "vol-9" } }), { status: 201 });
58 if (method === "DELETE") {
59 if (deleted.has(path)) return new Response("{}", { status: 404 });
60 deleted.add(path);
61 return new Response(null, { status: 204 });
62 }
63 return new Response(JSON.stringify({ droplet: { id: 2000 + calls.length } }), { status: 202 });
64 };
65 });
66
67 afterAll(async () => {
68 app.close();
69 await rm(dataDir, { recursive: true, force: true });
70 });
71
72 test("a failed first deploy keeps its volume, and the same name deploys again reusing it", async () => {
73 fail.set("POST /v2/droplets", 500);
74 const first = await deployPinned(app, admin(), input());
75 expect(first.ok).toBe(false);
76 // The volume id was recorded before the droplet was asked for.
77 expect(getPinned(app.db, "acme")).toMatchObject({ state: "failed", remote: "", volume: "vol-9" });
78 calls.length = 0;
79 const again = await deployPinned(app, admin(), input());
80 expect(again.ok).toBe(true);
81 expect(calls).toEqual(["POST /v2/droplets"]);
82 expect(getPinned(app.db, "acme")).toMatchObject({ state: "running", volume: "vol-9" });
83 // A running forge still owns its name.
84 expect(await deployPinned(app, admin(), input())).toMatchObject({ ok: false, error: "A pinned forge has that name already." });
85 });
86
87 test("an Update whose create fails after the delete can be rolled back or tried again", async () => {
88 fail.set("POST /v2/droplets", 500);
89 expect((await updatePinned(app, admin(), "acme", { version: V2 })).ok).toBe(false);
90 expect(getPinned(app.db, "acme")).toMatchObject({ state: "failed", remote: "", version: V2 });
91 calls.length = 0;
92 // The old droplet is not deleted a second time.
93 const back = await rollbackPinned(app, admin(), "acme");
94 expect(back.ok).toBe(true);
95 expect(calls).toEqual(["POST /v2/droplets"]);
96 expect(getPinned(app.db, "acme")).toMatchObject({ state: "running", version: V1 });
97 });
98
99 test("a droplet already gone (404) counts as deleted", async () => {
100 app.db.run("UPDATE pinned_forge SET remote = '777' WHERE name = 'acme'");
101 deleted.add("/v2/droplets/777");
102 expect((await updatePinned(app, admin(), "acme", { version: V2 })).ok).toBe(true);
103 expect(getPinned(app.db, "acme")).toMatchObject({ state: "running", version: V2 });
104 });
105
106 test("a failed forge may be told the version it already has, to finish the replacement", async () => {
107 app.db.run("UPDATE pinned_forge SET state = 'failed', remote = '' WHERE name = 'acme'");
108 expect((await updatePinned(app, admin(), "acme", { version: V2 })).ok).toBe(true);
109 expect(getPinned(app.db, "acme")?.state).toBe("running");
110 });
111
112 test("the boot script installs the Bun version it names, not whatever is latest", () => {
113 const script = bootConfig({ name: "acme", source: SOURCE, version: V1, config: {} });
114 expect(script).toContain(`bash -s "bun-v${BUN_VERSION}"`);
115 expect(BUN_VERSION).toMatch(/^\d+\.\d+\.\d+$/);
116 });
117
118 test("terminal: the palette offers Pinned forges to an admin only; d, u and r reach the actions", async () => {
119 const bob = new Session(app, { sshUsername: "t", remoteAddress: "127.0.0.1", user: getUserByName(app.db, "bob")! });
120 const alice = new Session(app, { sshUsername: "t", remoteAddress: "127.0.0.1", user: admin() });
121 expect(entry.offered(bob)).toBe(false);
122 expect(entry.offered(alice)).toBe(true);
123
124 const pushed: any[] = [];
125 const ctx = { push: (s: unknown) => pushed.push(s), invalidate() {}, pop() {}, toast() {}, setCursor() {} } as never;
126 const key = (s: { key?: (e: never) => boolean }, k: string) => s.key?.({ type: "key", key: k, ctrl: false, alt: false, shift: false } as never);
127 const refused = pinnedScreen(bob, ctx);
128 for (const k of ["d", "u", "r"]) key(refused, k);
129 expect(pushed).toEqual([]);
130
131 const screen = pinnedScreen(alice, ctx);
132 key(screen, "d");
133 expect(pushed).toHaveLength(1);
134 // r rolls back the forge under the cursor: one action, recorded.
135 const before = getPinned(app.db, "acme")!.version;
136 key(screen, "r");
137 await new Promise((r) => setTimeout(r, 50));
138 expect(getPinned(app.db, "acme")!.version).not.toBe(before);
139 });
140
141 test("terminal: the screen follows the bus while it is on the stack", async () => {
142 const alice = new Session(app, { sshUsername: "t", remoteAddress: "127.0.0.1", user: admin() });
143 let invalidated = 0;
144 const ctx = { push() {}, invalidate: () => invalidated++ } as never;
145 const abort = new AbortController();
146 await pinnedScreen(alice, ctx).load!(abort.signal);
147 const n = invalidated;
148 await updatePinned(app, admin(), "acme", { version: V2 });
149 await new Promise((r) => setTimeout(r, 120));
150 expect(invalidated).toBeGreaterThan(n);
151 abort.abort();
152 });
153 });
test/pinned-forge.test.ts added +226
@@ -0,0 +1,226 @@
1 /**
2 * A pinned forge (#8697): deployed once, changed only by an explicit Update
3 * that records who and when, rolled back in one action, and never holding a
4 * credential in a row, an event or the boot script. Every other way to move
5 * its version or config is tried here and refused.
6 */
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { addAccount, allowWrite, net as cloudNet } from "../src/actions/cloud.ts";
13 import { deployPinned, net, pinnedForges, rollbackPinned, updatePinned } from "../src/actions/pinned.ts";
14 import { bootConfig } from "../src/cloud/droplet.ts";
15 import { TARGETS } from "../src/cloud/targets.ts";
16 import { changesOf, checkConfig, getPinned } from "../src/core/pinned.ts";
17 import { createToken } from "../src/core/tokens.ts";
18 import { createUser, getUserByName } from "../src/core/users.ts";
19 import { serve, type Servers } from "../src/http/server.ts";
20 import { quietLogs } from "./helpers/globals.ts";
21
22 quietLogs();
23
24 const TOKEN = "dop_v1_9Zq4TxLm7Kp2Vb8nWcR5";
25 const V1 = "a".repeat(40);
26 const V2 = "b".repeat(40);
27 const SOURCE = "https://forge.example.org/trevato/forge";
28
29 describe("pinned forge", () => {
30 let dataDir = "";
31 let app: App;
32 let servers: Servers;
33 const calls: { method: string; url: string; auth: string | null; body: any }[] = [];
34 let failNext = false;
35
36 const admin = () => getUserByName(app.db, "alice")!;
37 const input = (over: Record<string, unknown> = {}) => ({
38 name: "acme", target: "digitalocean", account: "main", app: "alice/shop", source: SOURCE, version: V1, config: { PUBLIC_NAME: "Acme" }, ...over,
39 });
40
41 beforeAll(async () => {
42 dataDir = await mkdtemp(join(tmpdir(), "forge-pinned-"));
43 app = createApp({ dataDir, port: 0 });
44 servers = await serve(app);
45 for (const [username, adminIfFirst] of [["alice", true], ["bob", false]] as const) {
46 const made = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@f.test`, password: "a-good-passphrase", adminIfFirst });
47 if (!made.ok) throw new Error(made.error);
48 }
49 cloudNet.fetch = async () => new Response(JSON.stringify({ account: { email: "[email protected]", status: "active" } }), { status: 200 });
50 const added = await addAccount(app, admin(), "digitalocean", "main", TOKEN);
51 if (!added.ok) throw new Error(String(added.error));
52 net.fetch = async (url, init) => {
53 const u = new URL(url);
54 calls.push({ method: init?.method ?? "GET", url, auth: new Headers(init?.headers).get("authorization"), body: init?.body ? JSON.parse(String(init.body)) : undefined });
55 if (failNext) { failNext = false; return new Response("{}", { status: 500 }); }
56 if (u.pathname === "/v2/volumes") return new Response(JSON.stringify({ volume: { id: "vol-1" } }), { status: 201 });
57 if (init?.method === "DELETE") return new Response(null, { status: 204 });
58 return new Response(JSON.stringify({ droplet: { id: 1000 + calls.length } }), { status: 202 });
59 };
60 }, 30_000);
61
62 afterAll(async () => {
63 servers.stop();
64 app.close();
65 await rm(dataDir, { recursive: true, force: true });
66 });
67
68 test("a deploy needs a cloud account that may write", async () => {
69 const r = await deployPinned(app, admin(), input());
70 expect(r).toMatchObject({ ok: false });
71 expect(String((r as any).error)).toContain("read-only");
72 expect(calls).toHaveLength(0);
73 // The attempt is recorded, and the forge says it failed rather than running.
74 expect(getPinned(app.db, "acme")?.state).toBe("failed");
75 });
76
77 test("deploy makes a volume, then a droplet on it; the token is a header, never in a body or the boot script", async () => {
78 app.db.run("DELETE FROM pinned_forge");
79 allowWrite(app, admin(), "digitalocean", "main", true);
80 const r = await deployPinned(app, admin(), input());
81 expect(r.ok).toBe(true);
82 expect(calls.map((c) => `${c.method} ${new URL(c.url).pathname}`)).toEqual(["POST /v2/volumes", "POST /v2/droplets"]);
83 expect(calls.every((c) => c.auth === `Bearer ${TOKEN}`)).toBe(true);
84 const droplet = calls[1]!.body;
85 expect(droplet.volumes).toEqual(["vol-1"]);
86 expect(JSON.stringify(calls.map((c) => c.body))).not.toContain(TOKEN);
87 expect(droplet.user_data).toContain(`checkout --detach ${V1}`);
88 expect(droplet.user_data).toContain("FORGE_APP_DATA=/mnt/forge_acme_data");
89 const p = getPinned(app.db, "acme")!;
90 expect(p).toMatchObject({ state: "running", volume: "vol-1" });
91 const events = JSON.stringify(app.db.query("SELECT payload FROM event WHERE kind LIKE 'pinned.%'").all());
92 expect(events).not.toContain(TOKEN);
93 expect(JSON.stringify(pinnedForges(app, admin()))).not.toContain(TOKEN);
94 });
95
96 test("a pinned forge has no crew and no automatic release, and the schema keeps it so", () => {
97 const v = pinnedForges(app, admin());
98 expect(v.ok && v.value[0]).toMatchObject({ crew: false, autoRelease: false });
99 expect(() => app.db.run("UPDATE pinned_forge SET crew = 1")).toThrow();
100 expect(() => app.db.run("UPDATE pinned_forge SET auto_release = 1")).toThrow();
101 });
102
103 test("version and config move only through Update: every other way is refused", async () => {
104 const before = getPinned(app.db, "acme")!;
105 // Straight SQL, as any other code path in the forge would try.
106 expect(() => app.db.run("UPDATE pinned_forge SET version = ?1", [V2])).toThrow(/explicit Update/);
107 expect(() => app.db.run("UPDATE pinned_forge SET config = '{}'")).toThrow(/explicit Update/);
108 // Forging a change id that points at no matching change.
109 expect(() => app.db.run("UPDATE pinned_forge SET version = ?1, change_id = change_id + 1", [V2])).toThrow(/explicit Update/);
110 // A change recorded for one version cannot authorize another.
111 const c = app.db.query("INSERT INTO pinned_change (pinned_id, kind, from_version, to_version, from_config, to_config, by, at) VALUES (?1,'update',?2,?3,'{}','{}','x','now')").run(before.id, V1, V2);
112 expect(() => app.db.run("UPDATE pinned_forge SET version = ?1, config = ?2, change_id = ?3", ["c".repeat(40), "{}", Number(c.lastInsertRowid)])).toThrow(/explicit Update/);
113 // A deploy-kind change cannot be replayed as an update.
114 const d = app.db.query("SELECT id FROM pinned_change WHERE kind = 'deploy'").get() as { id: number };
115 expect(() => app.db.run("UPDATE pinned_forge SET version = ?1, change_id = ?2", [V2, d.id])).toThrow();
116 // History cannot be rewritten or deleted.
117 expect(() => app.db.run("UPDATE pinned_change SET by = 'someone'")).toThrow(/history/);
118 expect(() => app.db.run("DELETE FROM pinned_change")).toThrow(/history/);
119 expect(getPinned(app.db, "acme")!.version).toBe(before.version);
120 // Not an admin, not a thing.
121 const bob = getUserByName(app.db, "bob");
122 expect(await updatePinned(app, bob, "acme", { version: V2 })).toEqual({ ok: false, error: "not_found" });
123 expect(await rollbackPinned(app, undefined, "acme")).toEqual({ ok: false, error: "not_found" });
124 expect(getPinned(app.db, "acme")!.version).toBe(V1);
125 });
126
127 test("Update records who and when, replaces the droplet and keeps the volume", async () => {
128 calls.length = 0;
129 const r = await updatePinned(app, admin(), "acme", { version: V2, config: { PUBLIC_NAME: "Acme Ltd" } });
130 expect(r.ok).toBe(true);
131 expect(calls.map((c) => `${c.method} ${new URL(c.url).pathname}`)).toEqual([expect.stringMatching(/^DELETE \/v2\/droplets\/\d+$/), "POST /v2/droplets"]);
132 expect(calls[1]!.body.volumes).toEqual(["vol-1"]);
133 expect(calls[1]!.body.user_data).toContain(`checkout --detach ${V2}`);
134 const [latest] = changesOf(app.db, getPinned(app.db, "acme")!.id);
135 expect(latest).toMatchObject({ kind: "update", fromVersion: V1, toVersion: V2, by: admin().id });
136 expect(Date.parse(latest!.at)).toBeGreaterThan(0);
137 });
138
139 test("Update refuses an unsafe version, secret-looking config, and a no-op", async () => {
140 calls.length = 0;
141 for (const version of ["main", "--upload-pack=x", `${V1}; rm -rf /`, "v1.2"])
142 expect(await updatePinned(app, admin(), "acme", { version })).toMatchObject({ ok: false });
143 expect(await updatePinned(app, admin(), "acme", { config: { API_TOKEN: "x" } })).toMatchObject({ ok: false });
144 expect(await updatePinned(app, admin(), "acme", { config: { A: "$(id)" } })).toMatchObject({ ok: false });
145 expect(await updatePinned(app, admin(), "acme", {})).toMatchObject({ ok: false, error: "That is what it runs already." });
146 expect(calls).toHaveLength(0);
147 expect(getPinned(app.db, "acme")!.version).toBe(V2);
148 });
149
150 test("Rollback is one action to the previous version and is recorded", async () => {
151 const r = await rollbackPinned(app, admin(), "acme");
152 expect(r.ok && r.value).toMatchObject({ version: V1, config: { PUBLIC_NAME: "Acme" } });
153 const [latest] = changesOf(app.db, getPinned(app.db, "acme")!.id);
154 expect(latest).toMatchObject({ kind: "rollback", fromVersion: V2, toVersion: V1 });
155 });
156
157 test("a cloud failure leaves the change recorded and the forge marked failed", async () => {
158 failNext = true;
159 const r = await updatePinned(app, admin(), "acme", { version: V2 });
160 expect(r).toMatchObject({ ok: false });
161 expect(getPinned(app.db, "acme")).toMatchObject({ version: V2, state: "failed" });
162 expect(changesOf(app.db, getPinned(app.db, "acme")!.id)[0]).toMatchObject({ kind: "update", toVersion: V2 });
163 });
164
165 test("a forge with only its first version has nothing to roll back to", async () => {
166 app.db.run("UPDATE pinned_forge SET name = 'old' WHERE name = 'acme'");
167 const r = await deployPinned(app, admin(), input({ name: "fresh" }));
168 expect(r.ok).toBe(true);
169 expect(await rollbackPinned(app, admin(), "fresh")).toMatchObject({ ok: false });
170 });
171
172 test("Cloudflare and Vercel are refused with a reason; AWS and Azure are named as not written", async () => {
173 for (const target of ["cloudflare", "vercel", "aws", "azure"]) {
174 const r = await deployPinned(app, admin(), input({ name: `x-${target}`, target }));
175 expect(r.ok).toBe(false);
176 }
177 expect(TARGETS.map((t) => t.id)).toEqual(["digitalocean", "aws", "azure", "cloudflare", "vercel"]);
178 expect(getPinned(app.db, "x-vercel")).toBeUndefined();
179 });
180
181 test("boot script and config hold no shell metacharacter that user text could smuggle", () => {
182 expect(checkConfig({ A: "x\nB=1" }).ok).toBe(false);
183 expect(checkConfig({ A: 'x"y' }).ok).toBe(false);
184 expect(checkConfig({ "a b": "x" }).ok).toBe(false);
185 const script = bootConfig({ name: "acme", source: SOURCE, version: V1, config: { A: "b" } });
186 expect(script).toContain("A=b");
187 expect(script.startsWith("#cloud-config")).toBe(true);
188 });
189
190 test("HTTP: the admin routes list, update and roll back; a member sees nothing", async () => {
191 const tok = createToken(app.db, admin().id, "admin", "admin").token;
192 const bob = createToken(app.db, getUserByName(app.db, "bob")!.id, "admin", "admin").token;
193 const h = (t: string) => ({ authorization: `Bearer ${t}`, "content-type": "application/json" });
194 const list = await fetch(`${servers.url}/api/v1/admin/pinned`, { headers: h(tok) });
195 expect(list.status).toBe(200);
196 const body = (await list.json()) as any;
197 expect(body.targets).toHaveLength(5);
198 expect(body.items.length).toBeGreaterThan(0);
199 expect((await fetch(`${servers.url}/api/v1/admin/pinned`, { headers: h(bob) })).status).toBeGreaterThanOrEqual(403);
200 const up = await fetch(`${servers.url}/api/v1/admin/pinned/fresh/update`, { method: "POST", headers: h(tok), body: JSON.stringify({ version: V2 }) });
201 expect(up.status).toBe(200);
202 expect(((await up.json()) as any).version).toBe(V2);
203 const back = await fetch(`${servers.url}/api/v1/admin/pinned/fresh/rollback`, { method: "POST", headers: h(tok), body: "{}" });
204 expect(((await back.json()) as any).version).toBe(V1);
205 const bad = await fetch(`${servers.url}/api/v1/admin/pinned/fresh/update`, { method: "POST", headers: h(tok), body: JSON.stringify({ version: "main" }) });
206 expect(bad.status).toBe(422);
207 const missing = await fetch(`${servers.url}/api/v1/admin/pinned/nope/rollback`, { method: "POST", headers: h(tok), body: "{}" });
208 expect(missing.status).toBe(404);
209 });
210
211 test("MCP pinned_forge lists and updates for an admin-scope token", async () => {
212 const tok = createToken(app.db, admin().id, "admin", "admin").token;
213 const call = async (args: Record<string, unknown>) => {
214 const res = await fetch(`${servers.url}/mcp`, {
215 method: "POST",
216 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${tok}` },
217 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "pinned_forge", arguments: args } }),
218 });
219 const text = ((await res.json()) as any).result.content[0].text as string;
220 return text.startsWith("{") ? JSON.parse(text) : { text };
221 };
222 expect((await call({ verb: "list" })).items.length).toBeGreaterThan(0);
223 expect((await call({ verb: "update", name: "fresh", version: V2 })).says).toContain(V2);
224 expect((await call({ verb: "update", name: "fresh", version: "nope" })).text).toContain("full 40-character");
225 });
226 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs