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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11277-11153-is-closed-but-not-delivered-produc → main 6 commits fork 6 threads · 6 unresolved
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 | ||
| 40 | 40 | - `.door-press-actions` — a press's actions; its empty `data-slot="host"` is where a later "host it for me" sits. |
| 41 | 41 | - `.door-wide` — a visitor's top-bar link that a phone leaves out, so the bar never overflows. |
| 42 | 42 | - `.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 | ||
| 4 | 4 | |
| 5 | 5 | 1. Open `/`. The first view has one sentence, a "Forge name" field and the button "Start my forge". |
| 6 | 6 | 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. | |
| 8 | 11 | |
| 9 | 12 | 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`. |
| 10 | 13 | |
| 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`. | |
| 12 | 15 | |
| 13 | 16 | ## When the door refuses |
| 14 | 17 |
src/http/server.ts +2
| @@ -277,6 +277,7 @@ import { forgeLinkRevoke, forgeLinkSubmit, forgeLinkUndo, forgeRemoveConfirm, fo | ||
| 277 | 277 | forgeOwnCodeSubmit, forgesPage } from "../web/pages/forges.ts"; |
| 278 | 278 | import { forgeItSubmit } from "../web/pages/forge-it.ts"; |
| 279 | 279 | import { funnelSubmit } from "./funnel.ts"; |
| 280 | import { startForgeSubmit } from "../web/pages/start-forge.ts"; | |
| 280 | 281 | import { newForgePage, newForgeSubmit } from "../web/pages/new-forge.ts"; |
| 281 | 282 | import { askedPage } from "../web/studio/asked_page.ts"; |
| 282 | 283 | import { notesOverrule, notesSave, notesView } from "../web/pages/notes.ts"; |
| @@ -852,6 +853,7 @@ export function buildRouter(): Router<Handler> { | ||
| 852 | 853 | router.add("GET", "/forges/:name/ready", forgeReady); |
| 853 | 854 | router.add("GET", "/forges/new", newForgePage); |
| 854 | 855 | router.add("POST", "/forges/new", newForgeSubmit); |
| 856 | router.add("POST", "/forges/start", startForgeSubmit); | |
| 855 | 857 | // The front page as a stranger sees it, for anyone; a site admin there |
| 856 | 858 | // also gets the controls that decide it (#686). |
| 857 | 859 | router.add("GET", "/front", studioDoor); |
src/tui/forge.ts +2
| @@ -19,6 +19,7 @@ import { fleetCommand } from "./screens/fleet.ts"; | ||
| 19 | 19 | import { dataCommand } from "./screens/data.ts"; |
| 20 | 20 | import { packsCommand } from "./screens/packs.ts"; |
| 21 | 21 | import { getForgeCommand } from "./screens/getforge.ts"; |
| 22 | import { startForgeCommand } from "./screens/startforge.ts"; | |
| 22 | 23 | import { workCommand } from "./screens/work.ts"; |
| 23 | 24 | import { shellsScreen } from "./screens/shells.ts"; |
| 24 | 25 | import { forkTreeScreen } from "./screens/fork_tree.ts"; |
| @@ -243,6 +244,7 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 243 | 244 | }, |
| 244 | 245 | packsCommand(session, ctx), |
| 245 | 246 | getForgeCommand(session, ctx), |
| 247 | ...startForgeCommand(session, ctx), | |
| 246 | 248 | ...dataCommand(session, ctx), |
| 247 | 249 | fleetCommand(session, ctx), |
| 248 | 250 | { |
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 | /** | |
| 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"; | ||
| 28 | 28 | import { freePlanName, funnelJs, noteHomeVisit } from "../funnel_door.ts"; |
| 29 | 29 | import { publicPlans } from "../../settings/plans.ts"; |
| 30 | 30 | import { getDoorSettings } from "../../settings/door.ts"; |
| 31 | import { startNameJs } from "../pages/start-forge.ts"; | |
| 31 | 32 | import { GET_A_FORGE_PATH } from "../../billing/plan_words.ts"; |
| 32 | 33 | import { appsForRepos } from "../../core/apps.ts"; |
| 33 | 34 | import { WEIGHT_MAX, WEIGHT_NAMES } from "../../core/front.ts"; |
| @@ -796,12 +797,12 @@ export function studioDoor(ctx: Ctx): Response { | ||
| 796 | 797 | <div class="door-under"> |
| 797 | 798 | <div class="door-lead"> |
| 798 | 799 | <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"> | |
| 801 | 801 | ${refusal === undefined ? null : html`<p class="error" id="start-refused">${START_REFUSALS[refusal]}</p>`} |
| 802 | 802 | <div class="field"> |
| 803 | 803 | <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. |
||
| 805 | 806 | </div> |
| 806 | 807 | ${btn("Start my forge", { variant: "primary", size: "lg" })} |
| 807 | 808 | </form> |
| @@ -899,7 +900,7 @@ export function studioDoor(ctx: Ctx): Response { | ||
| 899 | 900 | 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."} |
| 900 | 901 | </p> |
| 901 | 902 | ${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> | |
| 903 | 904 | <p class="door-support">Or run it yourself, free and open source: <a href="${FORGE_REPO}">read the step-by-step guide</a>.</p> |
| 904 | 905 | <pre class="door-code"><code>git clone ${FORGE_REPO}.git |
| 905 | 906 | cd forge |
| @@ -926,7 +927,7 @@ bun run src/main.ts</code></pre> | ||
| 926 | 927 | title: "forge: software for what you're building, yours to keep", |
| 927 | 928 | description: "Your own forge, with a private AI crew that builds your apps, games and business tools. Open source, and yours to keep.", |
| 928 | 929 | body, |
| 929 | scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${funnelJs}`, | |
| 930 | scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${funnelJs}\n${startNameJs}`, | |
| 930 | 931 | jsonLd: siteStructuredData(origin, running().version), |
| 931 | 932 | ...(visitor |
| 932 | 933 | ? { |
src/web/style/content.ts +1
| @@ -262,6 +262,7 @@ form.inline { display: inline; margin-left: auto; } | ||
| 262 | 262 | .door-lead .bar { gap: 8px; flex-wrap: wrap; } |
| 263 | 263 | .door-start { width: 100%; max-width: 28rem; } |
| 264 | 264 | .door-start .btn { width: 100%; } |
| 265 | .door-says { min-height: 1lh; margin: 6px 0 0; } | |
| 265 | 266 | .door-agent { display: flex; flex-direction: column; gap: 8px; width: 100%; max-width: 520px; font-size: 13px; color: var(--muted-fg); } |
| 266 | 267 | .door-agent a { margin-left: 8px; color: var(--fg); text-decoration: underline; text-underline-offset: 3px; text-decoration-color: var(--border-strong); } |
| 267 | 268 | /* 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"; | ||
| 8 | 8 | import { err, ok } from "../src/kernel/result.ts"; |
| 9 | 9 | import { usePlacement, useProbe } from "../src/spawn/door.ts"; |
| 10 | 10 | import { resetAnonSpawns } from "../src/spawn/rate.ts"; |
| 11 | import { createSession } from "../src/core/sessions.ts"; | |
| 12 | import { createUser } from "../src/core/users.ts"; | |
| 11 | 13 | import { START_REFUSALS } from "../src/web/start_refusal.ts"; |
| 12 | 14 | |
| 13 | 15 | let dataDir: string; |
| @@ -27,7 +29,7 @@ afterAll(async () => { | ||
| 27 | 29 | test("a visitor with no cookies sees the start form on /", async () => { |
| 28 | 30 | const body = await (await fetch(`${servers.url}/`)).text(); |
| 29 | 31 | 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"'); | |
| 31 | 33 | expect(form).toMatch(/method="post"/i); |
| 32 | 34 | expect(form).toMatch(/<label[^>]*for="start-name"/); |
| 33 | 35 | 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 | ||
| 48 | 50 | }); |
| 49 | 51 | |
| 50 | 52 | 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`, { | |
| 52 | 54 | method: "POST", |
| 53 | 55 | headers: { "content-type": "application/x-www-form-urlencoded", origin: "https://evil.example" }, |
| 54 | 56 | body: "name=sneaky", |
| @@ -57,14 +59,41 @@ test("the form's POST without a same-origin header is refused by the door", asyn | ||
| 57 | 59 | expect([400, 403]).toContain(res.status); |
| 58 | 60 | }); |
| 59 | 61 | |
| 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 | ||
| 60 | 89 | // ---- a refused start says why on `/` (#11238) ---- |
| 61 | 90 | |
| 62 | 91 | let refusal: { reason: "exists" | "quota"; message: string } | undefined; |
| 63 | 92 | const start = (name: string) => |
| 64 | fetch(`${servers.url}/forges/new`, { | |
| 93 | fetch(`${servers.url}/forges/start`, { | |
| 65 | 94 | method: "POST", |
| 66 | 95 | headers: { "content-type": "application/x-www-form-urlencoded" }, |
| 67 | body: new URLSearchParams({ name, from: "home" }), | |
| 96 | body: new URLSearchParams({ name }), | |
| 68 | 97 | redirect: "manual", |
| 69 | 98 | }); |
| 70 | 99 | /** 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 () => { | ||
| 128 | 157 | expect(unknown).not.toContain('id="start-refused"'); |
| 129 | 158 | expect(unknown).not.toContain('value="x"'); |
| 130 | 159 | }); |
| 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(""><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", () => { | ||
| 366 | 366 | ["explore-feed", "Explore feed", "Atom address"], |
| 367 | 367 | ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"], |
| 368 | 368 | ["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"], | |
| 369 | 370 | ["fleet", "Fleet rows", "the rows a fleet query line selects"], |
| 370 | 371 | ["profile", "Open a profile", "username or org"], |
| 371 | 372 | ["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", | ||
| 92 | 92 | delete process.env["FORGE_BILLING_PROVIDER"]; |
| 93 | 93 | const section = await yours(); |
| 94 | 94 | 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"'); | |
| 96 | 96 | expect(section).not.toContain("/pricing"); |
| 97 | 97 | expect(section).not.toContain("$19 a month"); |
| 98 | 98 | expect(section).toContain("Paid"); |
| 99 | 99 | 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); | |
| 101 | 101 | }); |
| 102 | 102 | |
| 103 | 103 | 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 | })); |
Both doors: the same start has no screen in src/tui/. Add one, wired where AGENTS.md says, calling spawnDoor, with a test.