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

#11153 is closed but not delivered: production's home page has no name field and no "Start my forge" button #11281

Merged opened this in answer to

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

Merged

scout/forge:scout/11277-11153-is-closed-but-not-delivered-produc → main 6 commits fork 6 threads · 6 unresolved

  • Merged. scout/11277-11153-is-closed-but-not-delivered-produc was merged into main as 9759e67.

Review

Sign in to review this change.
12 files changed +435 −13
docs/design/areas/front.md +2
@@ -40,3 +40,5 @@ The page a visitor reads first runs past §3's working scale on purpose, in `cla
4040 - `.door-press-actions` — a press's actions; its empty `data-slot="host"` is where a later "host it for me" sits.
4141 - `.door-wide` — a visitor's top-bar link that a phone leaves out, so the bar never overflows.
4242 - `.door-start` — the hero's one form: a labelled forge-name field and "Start my forge", full width on a phone, posting to `/forges/new`.
43
44 `.door-says` is the one-line live note under the start form's name field. It keeps the height of one line so the page does not move when text appears.
docs/features/start-my-forge.md +5 −2
@@ -4,11 +4,14 @@ A signed-out visitor starts their own forge from the home page, with no account
44
55 1. Open `/`. The first view has one sentence, a "Forge name" field and the button "Start my forge".
66 2. Type a name (lowercase letters, digits and dashes) and press Enter or the button.
7 3. The form posts to `/forges/new`, the same door `/agents` uses: the free plan and the visitor rate limits apply, and a bad name comes back worded by the door. There is no second spawn path.
7 While you type, the field says "<name> is free" or "is taken" (it asks `GET /forges/<name>/ready`, where 404 means nothing by that name was made).
8 3. The form posts to `/forges/start` (`src/web/pages/start-forge.ts`), which calls the same `spawnDoor` as `/forges/new` and `/agents`: the free plan and the visitor rate limits apply, and a bad name comes back on the same page, worded by the door.
9 4. The answer says "Setting up <name>…" and polls `/forges/<name>/ready`; when it says yes the browser goes to the new forge's claim page (username and password) and lands signed in. No link is shown to copy; a plain "Open <name>" link holds the same address if scripts are off or the forge is slow.
10 5. A signed-in person posting the form is sent to `/forges/new`, with `?name=` only when the name is a valid forge name, which keeps its fuller form (what it is for, apps to fork, key, budget). The Free plan's "Get a forge" button on the home goes to this start form.
811
912 The field is a slug, so it turns off autocomplete and capitalisation on phones. The form is in `src/web/studio/home.ts`; its test is `test/home-start-forge.test.ts`.
1013
11 In the terminal, a guest's "Get your own forge" command prints the plans and the web address to start from; signed in, "New forge" spawns one.
14 In the terminal, a guest's "Start my forge" command (`src/tui/screens/startforge.ts`) asks for the name, goes through the same `spawnDoor` as a stranger (counted under the SSH client's address), shows a refusal in the same fixed sentences as the home page, and shows the claim link once. Signed in, "New forge" is the fuller form. Test: `test/tui-startforge.test.ts`.
1215
1316 ## When the door refuses
1417
src/http/server.ts +2
@@ -277,6 +277,7 @@ import { forgeLinkRevoke, forgeLinkSubmit, forgeLinkUndo, forgeRemoveConfirm, fo
277277 forgeOwnCodeSubmit, forgesPage } from "../web/pages/forges.ts";
278278 import { forgeItSubmit } from "../web/pages/forge-it.ts";
279279 import { funnelSubmit } from "./funnel.ts";
280 import { startForgeSubmit } from "../web/pages/start-forge.ts";
280281 import { newForgePage, newForgeSubmit } from "../web/pages/new-forge.ts";
281282 import { askedPage } from "../web/studio/asked_page.ts";
282283 import { notesOverrule, notesSave, notesView } from "../web/pages/notes.ts";
@@ -852,6 +853,7 @@ export function buildRouter(): Router<Handler> {
852853 router.add("GET", "/forges/:name/ready", forgeReady);
853854 router.add("GET", "/forges/new", newForgePage);
854855 router.add("POST", "/forges/new", newForgeSubmit);
856 router.add("POST", "/forges/start", startForgeSubmit);
855857 // The front page as a stranger sees it, for anyone; a site admin there
856858 // also gets the controls that decide it (#686).
857859 router.add("GET", "/front", studioDoor);
src/tui/forge.ts +2
@@ -19,6 +19,7 @@ import { fleetCommand } from "./screens/fleet.ts";
1919 import { dataCommand } from "./screens/data.ts";
2020 import { packsCommand } from "./screens/packs.ts";
2121 import { getForgeCommand } from "./screens/getforge.ts";
22 import { startForgeCommand } from "./screens/startforge.ts";
2223 import { workCommand } from "./screens/work.ts";
2324 import { shellsScreen } from "./screens/shells.ts";
2425 import { forkTreeScreen } from "./screens/fork_tree.ts";
@@ -243,6 +244,7 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
243244 },
244245 packsCommand(session, ctx),
245246 getForgeCommand(session, ctx),
247 ...startForgeCommand(session, ctx),
246248 ...dataCommand(session, ctx),
247249 fleetCommand(session, ctx),
248250 {
src/tui/screens/startforge.ts added +106
@@ -0,0 +1,106 @@
1 /**
2 * "Start my forge" in the terminal: the home page's start form for a guest.
3 * One field, the name; **Enter** goes through `spawnDoor` as a stranger, as
4 * `POST /forges/start` does, so the free plan, the name rules and the visitor
5 * limits are the door's (counted under the SSH client's address). A refusal is
6 * one of the fixed sentences the home page shows (`web/start_refusal.ts`); a
7 * made forge's claim link is shown once and kept nowhere. Signed in, "New
8 * forge" is the fuller form, so this command is a guest's.
9 */
10
11 import { notePeer } from "../../http/peer.ts";
12 import { spawnDoor } from "../../spawn/door.ts";
13 import { START_REFUSALS, startRefusalOf } from "../../web/start_refusal.ts";
14 import type { Ctx, Screen } from "../app/app.ts";
15 import { binding } from "../app/keys.ts";
16 import { style } from "../term/style.ts";
17 import { truncate, wrap } from "../term/width.ts";
18 import { field } from "../ui/widgets.ts";
19 import type { Session } from "../session.ts";
20 import { linkScreen, runAsync } from "./common.ts";
21
22 const HINT = "Lowercase letters, digits and dashes. Enter starts it.";
23
24 export function startForgeScreen(f: Session, ctx: Ctx): Screen {
25 let name = "";
26 let error: string | undefined;
27 let busy = false;
28
29 const start = (): void => {
30 if (busy) return;
31 busy = true;
32 error = undefined;
33 runAsync(ctx, async () => {
34 try {
35 const url = new URL(f.app.config.url ?? `http://${f.app.config.host ?? "localhost"}`);
36 const request = new Request(new URL("/forges/start", url).href, { method: "POST" });
37 notePeer(request, f.identity.remoteAddress);
38 const hctx = { app: f.app, request, url, params: {}, auth: { user: undefined, scope: undefined, via: "none" as const }, theme: undefined };
39 const made = await spawnDoor(hctx, undefined, { name: name.trim().toLowerCase(), dryRun: false });
40 const link = made.body["claimUrl"];
41 if (made.status >= 400 || typeof link !== "string") {
42 error = START_REFUSALS[made.status >= 400 ? startRefusalOf(made) : "taken"];
43 return;
44 }
45 ctx.toast(`${String(made.body["name"] ?? name)} is ready`, "success");
46 ctx.replace(linkScreen(ctx, link));
47 } finally {
48 busy = false;
49 ctx.invalidate();
50 }
51 });
52 };
53
54 return {
55 title: "Start my forge",
56 keys: [binding(["enter"], "start"), binding(["esc"], "back")],
57 render(area, s) {
58 const c = ctx.theme.c;
59 s.text(area.x, area.y, truncate("Start your own forge, free. Next you choose a username and password.", area.w), style(c.fg, c.bg), area.x + area.w);
60 const at = field(s, { x: area.x, y: area.y + 2, w: Math.min(60, area.w), h: 3 }, c, {
61 label: "Forge name",
62 value: name,
63 focused: true,
64 cursor: [...name].length,
65 hint: busy ? "Setting up…" : HINT,
66 });
67 if (error !== undefined)
68 wrap(error, Math.max(20, Math.min(60, area.w))).forEach((line, i) => {
69 if (6 + i < area.h) s.text(area.x, area.y + 6 + i, line, style(c.danger, c.bg), area.x + area.w);
70 });
71 ctx.setCursor(at);
72 },
73 paste(text) {
74 name += text.replace(/\s+/g, "");
75 error = undefined;
76 return true;
77 },
78 key(e) {
79 if (e.key === "esc") {
80 ctx.pop();
81 return true;
82 }
83 if (e.key === "enter") {
84 start();
85 return true;
86 }
87 if (e.key === "backspace") {
88 name = [...name].slice(0, -1).join("");
89 error = undefined;
90 return true;
91 }
92 if (!e.ctrl && !e.alt && [...e.key].length === 1 && e.key !== " ") {
93 name += e.key;
94 error = undefined;
95 return true;
96 }
97 return false;
98 },
99 };
100 }
101
102 /** A guest's: a signed-in person has "New forge". */
103 export const startForgeCommand = (session: Session, ctx: Ctx) =>
104 session.signedIn
105 ? []
106 : [{ id: "start-forge", title: "Start my forge", hint: "a free forge of your own; you choose a username and password next", run: () => ctx.push(startForgeScreen(session, ctx)) }];
src/web/pages/start-forge.ts added +80
@@ -0,0 +1,80 @@
1 /**

Both doors: the same start has no screen in src/tui/. Add one, wired where AGENTS.md says, calling spawnDoor, with a test.

2 * `POST /forges/start` (#11277, #11153): the home page's "Start my forge".
3 * A stranger gives a name; the same door as `/forges/new` (`spawnDoor`, the free
4 * plan, the visitor limits) makes the forge; the answer is a page that says
5 * "Setting up <name>…", asks `GET /forges/<name>/ready` until it says yes, and
6 * then goes straight to the new forge's claim page (username and password,
7 * signed in on landing). The claim link is in this one no-store body, as the
8 * target of a link and a redirect: never shown as text, never to copy.
9 * A signed-in person is sent to the fuller `/forges/new`, which keeps its form.
10 */
11 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
12 import type { Ctx } from "../../http/context.ts";
13 import { spawnDoor } from "../../spawn/door.ts";
14 import { noteForgeMade } from "../funnel_door.ts";
15 import { field, readForm } from "../form.ts";
16 import { html, type Html } from "../html.ts";
17 import { page, redirect } from "../layout.ts";
18 import { notFoundPage, pageHead } from "../chrome.ts";
19 import { startRefusalHref, startRefusalOf } from "../start_refusal.ts";
20
21 /** The home's live name check: 404 from the ready probe means nothing by that name was made here. */
22 export const startNameJs = `(function(d){
23 var i=d.getElementById("start-name"),s=d.getElementById("start-name-says"),t,k=0;
outdated

Two inline JS blobs (live check, polling and redirect) have no test that would fail without them; only the markup is asserted. Add tests of their behaviour, or of the endpoints and data attributes they depend on, including the 404 means free reading and the timeout message.

24 if(!i||!s||!window.fetch)return;
25 function say(m){s.textContent=m}
26 i.addEventListener("input",function(){
27 clearTimeout(t);var n=i.value.trim().toLowerCase(),q=++k;
28 if(!n){say("");return}
29 if(!/^[a-z0-9][a-z0-9-]*$/.test(n)){say("Use lowercase letters, digits and dashes.");return}
30 t=setTimeout(function(){fetch("/forges/"+encodeURIComponent(n)+"/ready",{credentials:"omit"}).then(function(r){
31 if(q!==k)return;say(r.status===404?n+" is free.":r.status===200?n+" is taken. Try another.":"")}).catch(function(){})},300)})
32 })(document)`;
33
34 /** Waits for the forge, then goes to its claim page; after a minute and a half the link is still there. */
35 export const startWaitJs = `(function(d){
36 var a=d.getElementById("start-go"),s=d.getElementById("start-wait");
37 if(!a||!window.fetch)return;
38 var n=a.getAttribute("data-name"),u=a.getAttribute("data-href"),tries=0;
39 function poll(){
40 fetch("/forges/"+encodeURIComponent(n)+"/ready",{credentials:"omit"}).then(function(r){return r.json()}).then(function(j){
41 if(j&&j.ready===true){location.replace(u);return}throw 0}).catch(function(){
42 if(++tries>60){s.textContent=n+" is taking longer than usual. Use the link to open it.";return}
43 setTimeout(poll,1500)})}
44 poll()
45 })(document)`;
46
47 /** A forge name the fuller form may be given: 1 to 39 lowercase letters, digits and inner dashes. */
48 const SLUG = /^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/;
49
50 /** The waiting page's body: one sentence, and a plain link that holds the claim address if scripts are off. */
51 export function startWaitBody(name: string, link: string): Html {
52 return html`<div class="narrow stack" data-start-wait>
53 ${pageHead("Start my forge")}
54 <p id="start-wait" role="status">Setting up ${name}. Next you choose a username and password.</p>
55 <p id="start-go" class="small" data-name="${name}" data-href="${link}"><a href="${link}">Open ${name}</a></p>
56 </div>`;
outdated

Signed-in branch redirects with a name that has not been validated (only truncated to 38). Validate at the boundary or drop the name, and add a test for this branch; none exists. Also no TUI screen exists for this flow (both doors).

57 }
58
59 export async function startForgeSubmit(ctx: Ctx): Promise<Response> {
60 const user = ctx.auth.user;
61 if (user && user.kind !== "user") return notFoundPage(ctx);
62 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
63 const form = await readForm(ctx, user === undefined ? undefined : "admin");
64 if (form instanceof Response) return form;
65 const typed = field(form, "name").trim();
66 const name = typed.toLowerCase();
67 if (user !== undefined) return redirect(SLUG.test(name) ? `/forges/new?name=${encodeURIComponent(name)}` : "/forges/new");
68 const door = await spawnDoor(ctx, undefined, { name, dryRun: false });
outdated

Principle 1 and 8: this page says the same thing three times (Setting up, Next you choose..., and the address) and the muted line mixes a sentence with a bare URL. One confident sentence, plain words per docs/WRITING.md; show the address as its own dl row or drop it.

69 if (door.status >= 400) {
outdated

Principle 5: a primary Continue button next to a page whose only job is to wait makes it a second accent target; with JS on it redirects anyway. Make it a plain link (or non-primary) so the page has one accent. The link is also placed in a data-href attribute; fine if escaped, but add a test with a hostile name/link to prove the escaping.

70 // Said on the home page, with the name kept (#11238).
71 return redirect(startRefusalHref(startRefusalOf(door), typed));
72 }
73 const link = door.body["claimUrl"];
74 if (typeof link !== "string") return redirect(startRefusalHref("taken", name));
75 noteForgeMade(ctx);
76 const body = startWaitBody(name, link);
77 const response = page({ ctx, title: "Setting up…", body, scriptJs: startWaitJs });
78 response.headers.set("cache-control", "no-store");
79 return response;
80 }
src/web/studio/home.ts +6 −5
@@ -28,6 +28,7 @@ import { plansRow } from "../plans_row.ts";
2828 import { freePlanName, funnelJs, noteHomeVisit } from "../funnel_door.ts";
2929 import { publicPlans } from "../../settings/plans.ts";
3030 import { getDoorSettings } from "../../settings/door.ts";
31 import { startNameJs } from "../pages/start-forge.ts";
3132 import { GET_A_FORGE_PATH } from "../../billing/plan_words.ts";
3233 import { appsForRepos } from "../../core/apps.ts";
3334 import { WEIGHT_MAX, WEIGHT_NAMES } from "../../core/front.ts";
@@ -796,12 +797,12 @@ export function studioDoor(ctx: Ctx): Response {
796797 <div class="door-under">
797798 <div class="door-lead">
798799 <p class="door-support">Say what you need and an AI crew builds it in your own forge, free to try.</p>
799 <form method="post" action="/forges/new" class="door-start" id="start-forge">
800 <input type="hidden" name="from" value="home" />
800 <form method="post" action="/forges/start" class="door-start" id="start-forge">
801801 ${refusal === undefined ? null : html`<p class="error" id="start-refused">${START_REFUSALS[refusal]}</p>`}
802802 <div class="field">
803803 <label for="start-name">Forge name</label>
804 <input class="input" id="start-name" name="name" type="text" value="${keptName}" ${refusal === undefined ? null : html`aria-invalid="true" aria-describedby="start-refused"`} required autocomplete="off" autocapitalize="none" spellcheck="false" />
804 <input class="input" id="start-name" name="name" type="text" value="${keptName}" aria-describedby="${refusal === undefined ? "start-name-says" : "start-refused start-name-says"}" ${refusal === undefined ? null : html`aria-invalid="true"`} required autocomplete="off" autocapitalize="none" spellcheck="false" />
805 <p class="muted small door-says" id="start-name-says" role="status" aria-live="polite"></p>
outdated

Principle 3/8: an always-present empty role=status paragraph under the field adds a line of layout space (check no layout shift when text appears: reserve its height) and the messages (is free / is taken) are not in docs/WRITING.md voice. Reserve the height with a token-based rule, no hand-rolled styles.

805806 </div>
806807 ${btn("Start my forge", { variant: "primary", size: "lg" })}
807808 </form>
@@ -899,7 +900,7 @@ export function studioDoor(ctx: Ctx): Response {
899900 Hosted for you, or free and open source on a machine you keep.${accounts ? "" : mode === "invite" ? " New accounts on this forge are by invitation." : " This forge is not taking new accounts."}
900901 </p>
901902 ${plansRow(publicPlans(allPlans), links)}
902 <div class="bar" data-plan="${freePlanName(allPlans)}">${btn("Get a forge", { href: GET_A_FORGE_PATH, variant: "primary", size: "lg" })}</div>
903 <div class="bar" data-plan="${freePlanName(allPlans)}">${btn("Get a forge", { href: visitor ? "#start-forge" : GET_A_FORGE_PATH, variant: "primary", size: "lg" })}</div>
903904 <p class="door-support">Or run it yourself, free and open source: <a href="${FORGE_REPO}">read the step-by-step guide</a>.</p>
904905 <pre class="door-code"><code>git clone ${FORGE_REPO}.git
905906 cd forge
@@ -926,7 +927,7 @@ bun run src/main.ts</code></pre>
926927 title: "forge: software for what you're building, yours to keep",
927928 description: "Your own forge, with a private AI crew that builds your apps, games and business tools. Open source, and yours to keep.",
928929 body,
929 scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${funnelJs}`,
930 scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${funnelJs}\n${startNameJs}`,
930931 jsonLd: siteStructuredData(origin, running().version),
931932 ...(visitor
932933 ? {
src/web/style/content.ts +1
@@ -262,6 +262,7 @@ form.inline { display: inline; margin-left: auto; }
262262 .door-lead .bar { gap: 8px; flex-wrap: wrap; }
263263 .door-start { width: 100%; max-width: 28rem; }
264264 .door-start .btn { width: 100%; }
265 .door-says { min-height: 1lh; margin: 6px 0 0; }
265266 .door-agent { display: flex; flex-direction: column; gap: 8px; width: 100%; max-width: 520px; font-size: 13px; color: var(--muted-fg); }
266267 .door-agent a { margin-left: 8px; color: var(--fg); text-decoration: underline; text-underline-offset: 3px; text-decoration-color: var(--border-strong); }
267268 /* On the input's own tinted ground the muted color falls short of AA; the words are the thing to copy. */
test/home-start-forge.test.ts +148 −4
@@ -8,6 +8,8 @@ import { serve, type Servers } from "../src/http/server.ts";
88 import { err, ok } from "../src/kernel/result.ts";
99 import { usePlacement, useProbe } from "../src/spawn/door.ts";
1010 import { resetAnonSpawns } from "../src/spawn/rate.ts";
11 import { createSession } from "../src/core/sessions.ts";
12 import { createUser } from "../src/core/users.ts";
1113 import { START_REFUSALS } from "../src/web/start_refusal.ts";
1214
1315 let dataDir: string;
@@ -27,7 +29,7 @@ afterAll(async () => {
2729 test("a visitor with no cookies sees the start form on /", async () => {
2830 const body = await (await fetch(`${servers.url}/`)).text();
2931 const form = body.match(/<form[^>]*id="start-forge"[\s\S]*?<\/form>/)?.[0] ?? "";
30 expect(form).toContain('action="/forges/new"');
32 expect(form).toContain('action="/forges/start"');
3133 expect(form).toMatch(/method="post"/i);
3234 expect(form).toMatch(/<label[^>]*for="start-name"/);
3335 expect(form).toContain('id="start-name"');
@@ -48,7 +50,7 @@ test("the first view has one primary button, and the first sentence is one", asy
4850 });
4951
5052 test("the form's POST without a same-origin header is refused by the door", async () => {
51 const res = await fetch(`${servers.url}/forges/new`, {
53 const res = await fetch(`${servers.url}/forges/start`, {
5254 method: "POST",
5355 headers: { "content-type": "application/x-www-form-urlencoded", origin: "https://evil.example" },
5456 body: "name=sneaky",
@@ -57,14 +59,41 @@ test("the form's POST without a same-origin header is refused by the door", asyn
5759 expect([400, 403]).toContain(res.status);
5860 });
5961
62 test("the plans button goes to the start form, not to /forges/new", async () => {
63 const body = await (await fetch(`${servers.url}/`)).text();
64 const section = body.slice(body.indexOf('id="yours"'));
65 expect(section).toContain('href="#start-forge"');
66 expect(body).toContain('id="start-name-says"');
67 });
68
69 test("starting holds on 'Setting up', waits on ready, and leads to the claim page", async () => {
70 const res = await fetch(`${servers.url}/forges/start`, {
71 method: "POST",
72 headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url },
73 body: "name=Sunny-Day",
74 });
75 const body = await res.text();
76 if (res.status >= 400) throw new Error(`refused ${res.status}: ${body.slice(0, 300)}`);
77 expect(res.headers.get("cache-control")).toBe("no-store");
78 expect(body).toContain("Setting up sunny-day.");
79 expect(body).not.toContain("btn-primary");
80 expect(body).toContain('id="start-go"');
81 expect(body).toContain("/forges/");
82 expect(body).toMatch(/data-href="[^"]*claim/);
83 // No link to copy, no "What it is for", no apps to fork on this path.
84 expect(body).not.toContain("Send this link");
85 expect(body).not.toContain("What it is for");
86 expect(body).not.toContain("Apps to fork");
87 });
88
6089 // ---- a refused start says why on `/` (#11238) ----
6190
6291 let refusal: { reason: "exists" | "quota"; message: string } | undefined;
6392 const start = (name: string) =>
64 fetch(`${servers.url}/forges/new`, {
93 fetch(`${servers.url}/forges/start`, {
6594 method: "POST",
6695 headers: { "content-type": "application/x-www-form-urlencoded" },
67 body: new URLSearchParams({ name, from: "home" }),
96 body: new URLSearchParams({ name }),
6897 redirect: "manual",
6998 });
7099 /** Follow the refusal to `/` the way a browser does: one redirect, then the page. */
@@ -128,3 +157,118 @@ test("the name is escaped and an unknown kind shows nothing", async () => {
128157 expect(unknown).not.toContain('id="start-refused"');
129158 expect(unknown).not.toContain('value="x"');
130159 });
160
161 // ---- the page's scripts, run against a stand-in document (#11281) ----
162
163 type Fake = { value: string; textContent: string; attrs: Record<string, string>; on: Record<string, () => void> };
164 const fake = (attrs: Record<string, string> = {}, value = ""): Fake => ({ value, textContent: "", attrs, on: {} });
165
166 /** Runs a page script with fake elements, a scripted fetch and instant timers; returns what it asked for. */
167 function runScript(js: string, els: Record<string, Fake>, answer: (url: string) => { status: number; json?: unknown } | "fail") {
168 const asked: string[] = [];
169 const doc = {
170 getElementById: (id: string) =>
171 els[id] === undefined
172 ? null
173 : Object.assign(els[id], {
174 addEventListener: (ev: string, fn: () => void) => (els[id]!.on[ev] = fn),
175 getAttribute: (k: string) => els[id]!.attrs[k],
176 }),
177 };
178 const gone: string[] = [];
179 const g = globalThis as unknown as Record<string, unknown>;
180 const saved = { window: g["window"], fetch: g["fetch"], location: g["location"], setTimeout: g["setTimeout"] };
181 g["window"] = { fetch: true };
182 g["location"] = { replace: (u: string) => gone.push(u) };
183 g["fetch"] = (url: string) => {
184 asked.push(url);
185 const a = answer(url);
186 return a === "fail" ? Promise.reject(new Error("down")) : Promise.resolve({ status: a.status, json: () => Promise.resolve(a.json) });
187 };
188 g["setTimeout"] = (fn: () => void) => queueMicrotask(fn);
189 const restore = () => Object.assign(g, saved);
190 new Function("document", `return ${js.replace(/\(document\)\s*$/, "")}`)(doc)(doc);
191 return { asked, gone, restore };
192 }
193 const settle = async () => {
194 for (let i = 0; i < 400; i++) await Promise.resolve();
195 };
196
197 test("the live check: 404 reads free, 200 reads taken, a bad name never asks", async () => {
198 const { startNameJs } = await import("../src/web/pages/start-forge.ts");
199 const input = fake({}, "Sunny-Day");
200 const says = fake();
201 const run = runScript(startNameJs, { "start-name": input, "start-name-says": says }, (u) => ({ status: u.includes("sunny-day") ? 404 : 200 }));
202 try {
203 input.on["input"]!();
204 await settle();
205 expect(run.asked).toEqual(["/forges/sunny-day/ready"]);
206 expect(says.textContent).toBe("sunny-day is free.");
207 input.value = "taken";
208 input.on["input"]!();
209 await settle();
210 expect(says.textContent).toBe("taken is taken. Try another.");
211 input.value = "No Good!";
212 input.on["input"]!();
213 expect(says.textContent).toBe("Use lowercase letters, digits and dashes.");
214 expect(run.asked.length).toBe(2);
215 input.value = "";
216 input.on["input"]!();
217 expect(says.textContent).toBe("");
218 } finally {
219 run.restore();
220 }
221 });
222
223 test("the wait script goes to the claim page when ready, and says so when it takes too long", async () => {
224 const { startWaitJs } = await import("../src/web/pages/start-forge.ts");
225 const attrs = { "data-name": "sunny-day", "data-href": "/forges/sunny-day/claim?t=x" };
226 let n = 0;
227 const ready = runScript(startWaitJs, { "start-go": fake(attrs), "start-wait": fake() }, () => (++n < 3 ? { status: 200, json: { ready: false } } : { status: 200, json: { ready: true } }));
228 try {
229 await settle();
230 expect(ready.asked.every((u) => u === "/forges/sunny-day/ready")).toBe(true);
231 expect(ready.gone).toEqual(["/forges/sunny-day/claim?t=x"]);
232 } finally {
233 ready.restore();
234 }
235 const wait = fake();
236 const slow = runScript(startWaitJs, { "start-go": fake(attrs), "start-wait": wait }, () => "fail");
237 try {
238 await settle();
239 await settle();
240 expect(slow.gone).toEqual([]);
241 expect(wait.textContent).toBe("sunny-day is taking longer than usual. Use the link to open it.");
242 } finally {
243 slow.restore();
244 }
245 });
246
247 test("the waiting body escapes its name and link", async () => {
248 const { startWaitBody } = await import("../src/web/pages/start-forge.ts");
249 const out = startWaitBody('"><script>x</script>', '/c?a="><img src=x onerror=1>').toString();
250 expect(out).not.toContain("<script>");
251 expect(out).not.toContain("<img");
252 expect(out).toContain("&quot;&gt;&lt;img");
253 });
254
255 // ---- a signed-in poster goes to the fuller form, with a checked name only ----
256
257 test("a signed-in start redirects to /forges/new, keeping only a valid name", async () => {
258 const r = await createUser(app.db, { origin: "seed", trusted: true, username: "ann", allowReserved: true, email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
259 if (!r.ok) throw new Error(r.error);
260 const cookie = `forge_session=${createSession(app.db, r.value.id).token}`;
261 const post = async (name: string) => {
262 const res = await fetch(`${servers.url}/forges/start`, {
263 method: "POST",
264 redirect: "manual",
265 headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url, cookie },
266 body: `name=${encodeURIComponent(name)}`,
267 });
268 return res.headers.get("location") ?? "";
269 };
270 expect(await post("Sunny-Day")).toBe("/forges/new?name=sunny-day");
271 expect(await post('"><script>')).toBe("/forges/new");
272 expect(await post("-bad-")).toBe("/forges/new");
273 expect(await post("a".repeat(60))).toBe("/forges/new");
274 });
test/palette-roster.test.ts +1
@@ -366,6 +366,7 @@ test("signed out, the palette offers sign-in and the public entries", () => {
366366 ["explore-feed", "Explore feed", "Atom address"],
367367 ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"],
368368 ["get-a-forge", "Get your own forge", "the plans, where to get a hosted forge, how to run one yourself"],
369 ["start-forge", "Start my forge", "a free forge of your own; you choose a username and password next"],
369370 ["fleet", "Fleet rows", "the rows a fleet query line selects"],
370371 ["profile", "Open a profile", "username or org"],
371372 ["repo", "Open a repository", "owner/name"],
test/plans-row.test.ts +2 −2
@@ -92,12 +92,12 @@ test("the signed-out home shows each plan and one button to a page that exists",
9292 delete process.env["FORGE_BILLING_PROVIDER"];
9393 const section = await yours();
9494 for (const p of DEFAULT_PLANS) expect(section).toContain(p.name);
95 expect(section).toContain('href="/forges/new"');
95 expect(section).toContain('href="#start-forge"');
9696 expect(section).not.toContain("/pricing");
9797 expect(section).not.toContain("$19 a month");
9898 expect(section).toContain("Paid");
9999 expect(section.match(/data-size="lg"/g)).toHaveLength(1);
100 expect((await fetch(`${servers.url}/forges/new`)).status).toBe(200);
100 expect((await fetch(`${servers.url}/`)).status).toBe(200);
101101 });
102102
103103 test("with FORGE_BILLING_PROVIDER=stripe the real home shows the prices, read per request", async () => {
test/tui-startforge.test.ts added +80
@@ -0,0 +1,80 @@
1 /**
2 * "Start my forge" in the terminal: a guest's start, through the same
3 * `spawnDoor` as the home page's form, the claim link once, refusals in the
4 * home page's fixed sentences.
5 */
6 import { afterAll, beforeAll, expect, test } from "bun:test";
7 import { mkdtemp, readdir, 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 { createUser, type User } from "../src/core/users.ts";
12 import { usePlacement, useProbe } from "../src/spawn/door.ts";
13 import { hostedPlacement } from "../src/spawn/placement.ts";
14 import { createForgeApp, forgeCommands, Session } from "../src/tui/forge.ts";
15 import { startForgeScreen } from "../src/tui/screens/startforge.ts";
16 import { headlessTerminal } from "../src/tui/term/terminal.ts";
17 import { START_REFUSALS } from "../src/web/start_refusal.ts";
18 import { quietLogs } from "./helpers/globals.ts";
19 import { settleApp } from "./helpers/tui.ts";
20
21 quietLogs();
22
23 let dir: string;
24 let app: App;
25 let bob: User;
26 const identity = (user?: User) => ({ sshUsername: user?.username ?? "git", remoteAddress: "203.0.113.9", fingerprint: "SHA256:test", user });
27 const children = async () => (await readdir(join(dir, "spawn")).catch(() => [] as string[])).length;
28
29 beforeAll(async () => {
30 dir = await mkdtemp(join(tmpdir(), "forge-tui-startforge-"));
31 app = createApp({ dataDir: dir, url: "https://forge.test", port: 0 });
32 const r = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase", isAdmin: false });
33 if (!r.ok) throw new Error(r.error);
34 bob = r.value;
35 useProbe(app, () => async () => true);
36 usePlacement(app, hostedPlacement({ host: "forge.test" }));
37 });
38 afterAll(async () => {
39 app.db.close();
40 await rm(dir, { recursive: true, force: true });
41 });
42
43 async function withScreen(body: (h: { tui: ReturnType<typeof createForgeApp>["app"]; terminal: ReturnType<typeof headlessTerminal> }) => Promise<void>) {
44 const terminal = headlessTerminal(120, 30);
45 const { app: tui, session } = createForgeApp(app, identity(), { terminal, exitHooks: false });
46 tui.start();
47 tui.ctx.push(startForgeScreen(session, tui.ctx));
48 try {
49 await settleApp(tui);
50 await body({ tui, terminal });
51 } finally {
52 tui.stop?.();
53 }
54 }
55
56 test("the palette offers it to a guest; a signed-in person has New forge instead", () => {
57 const ids = (u?: User) => forgeCommands(new Session(app, identity(u)))({} as never).map((c) => c.id);
58 expect(ids()).toContain("start-forge");
59 expect(ids(bob)).not.toContain("start-forge");
60 expect(ids(bob)).toContain("new-forge");
61 });
62
63 test("a bad name is refused in the home page's words and nothing is made", () =>
64 withScreen(async ({ tui, terminal }) => {
65 const before = await children();
66 terminal.feed("-bad-\r");
67 await settleApp(tui);
68 expect(tui.screen.toText().replace(/\s+/g, " ")).toContain(START_REFUSALS.name);
69 expect(await children()).toBe(before);
70 }));
71
72 test("a name and Enter make the forge through the door and show its claim link", () =>
73 withScreen(async ({ tui, terminal }) => {
74 const before = await children();
75 expect(tui.screen.toText()).toContain("Forge name");
76 terminal.feed("Sunny-Day\r");
77 await settleApp(tui);
78 expect(await children()).toBe(before + 1);
79 expect(tui.screen.toText()).toMatch(/https:\/\/sunny-day--forge\.test\/claim#/);
80 }));

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs