Public: anyone can read this; the crew can change it.
A refused "Start my forge" says why on the home page, with what to do next #11272
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11238-a-refused-start-my-forge-says-why-on-the → main 4 commits fork 2 threads · 2 unresolved
5 files
changed +132
−2
docs/features/start-my-forge.md +4
| @@ -9,3 +9,7 @@ A signed-out visitor starts their own forge from the home page, with no account | ||
| 9 | 9 | 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 | 10 | |
| 11 | 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. |
| 12 | ||
| 13 | ## When the door refuses | |
| 14 | ||
| 15 | The form carries `from=home`. If the door refuses a stranger's start (rate limit, name taken, bad or reserved name, no room), they get a 303 back to `/?start=<kind>&name=<name>` and the home page shows one fixed sentence (`src/web/start_refusal.ts`) above the field, with the name kept. Only a kind from a fixed list is read; the door's own message, codes and times are never shown. Tests: `test/home-start-forge.test.ts`. |
src/web/pages/new-forge.ts +7 −1
| @@ -34,7 +34,8 @@ import { notFoundPage, pageHead } from "../chrome.ts"; | ||
| 34 | 34 | import { field, readForm, type Form } from "../form.ts"; |
| 35 | 35 | import { html, type Html } from "../html.ts"; |
| 36 | 36 | import { noteForgeMade } from "../funnel_door.ts"; |
| 37 | import { page } from "../layout.ts"; | |
| 37 | import { page, redirect } from "../layout.ts"; | |
| 38 | import { startRefusalHref, startRefusalOf } from "../start_refusal.ts"; | |
| 38 | 39 | import { pickList } from "../pick.ts"; |
| 39 | 40 | import { btn, field as formField, row, selectField } from "../ui.ts"; |
| 40 | 41 | |
| @@ -539,6 +540,11 @@ export async function newForgeSubmit(ctx: Ctx): Promise<Response> { | ||
| 539 | 540 | ...(values.code === "" ? {} : { code: values.code }), |
| 540 | 541 | dryRun: planOnly, |
| 541 | 542 | }); |
| 543 | // The home page's own form says it came from there: a refusal goes back to `/` with the name kept. | |
| 544 | if (door.status >= 400 && user === undefined && field(form, "from") === "home" && door.status !== 401 && door.status !== 403) { | |
| 545 | const kind = startRefusalOf(door); | |
| 546 | return redirect(startRefusalHref(kind, values.name)); | |
| 547 | } | |
| 542 | 548 | if (door.status >= 400) |
| 543 | 549 | return page( |
| 544 | 550 | { |
src/web/start_refusal.ts added +38
| @@ -0,0 +1,38 @@ | ||
| 1 | /** | |
| 2 | * Why the door refused "Start my forge" on the home page (#11238), in one | |
| 3 | * plain sentence each. The door's own message speaks to the API (codes, | |
| 4 | * counts, timestamps); the home page shows only these fixed sentences, so a | |
| 5 | * refusal never leaks a raw value and nothing the visitor typed is echoed | |
| 6 | * except the name, escaped, back in its field. | |
| 7 | */ | |
| 8 | ||
| 9 | export type StartRefusal = "rate" | "taken" | "name" | "full" | "other"; | |
| 10 | ||
| 11 | export const START_REFUSALS: Readonly<Record<StartRefusal, string>> = { | |
| 12 | rate: "Too many forges have been started from your connection lately. Wait a few minutes, or try again tomorrow, and press Start my forge again.", | |
| 13 | taken: "That name is already taken. Change it a little, for example add your own name or a number, and press Start my forge again.", | |
| 14 | name: "That name can't be used. Use 1 to 39 lowercase letters, digits or dashes, not starting or ending with a dash, and press Start my forge again.", | |
| 15 | full: "This forge has no room for another forge right now. Try again later, or come back tomorrow.", | |
| 16 | other: "We couldn't start your forge this time. Press Start my forge to try again, or come back in a few minutes.", | |
| 17 | }; | |
| 18 | ||
| 19 | /** The refusal kind a door answer names, by its status and error code. */ | |
| 20 | export function startRefusalOf(door: { readonly status: number; readonly body: Record<string, unknown> }): StartRefusal { | |
| 21 | const error = door.body["error"]; | |
| 22 | const message = String(door.body["message"] ?? ""); | |
| 23 | if (door.status === 429 || error === "rate_limited") return "rate"; | |
| 24 | if (error === "exists" || (error === "refused" && /already exists|being made/.test(message))) return "taken"; | |
| 25 | if (error === "invalid" && /^name: /.test(message)) return "name"; | |
| 26 | if (error === "refused" && /forges sent|room|quota|deep|capacity|limit/i.test(message)) return "full"; | |
| 27 | return "other"; | |
| 28 | } | |
| 29 | ||
| 30 | /** `?start=<kind>` back to a kind, or undefined for anything else. */ | |
| 31 | export function startRefusalIn(value: string | null): StartRefusal | undefined { | |
| 32 | return value !== null && Object.hasOwn(START_REFUSALS, value) ? (value as StartRefusal) : undefined; | |
| 33 | } | |
| 34 | ||
| 35 | /** Where the home page is told of a refusal: the kind and the name, kept. */ | |
| 36 | export function startRefusalHref(kind: StartRefusal, name: string): string { | |
| 37 | return `/?start=${kind}&name=${encodeURIComponent(name.slice(0, 39))}#start-forge`; | |
| 38 | } |
src/web/studio/home.ts +7 −1
| @@ -42,6 +42,7 @@ import { field, readForm, type Form } from "../form.ts"; | ||
| 42 | 42 | import { liveRegionsJs } from "../client.ts"; |
| 43 | 43 | import { html, when, type Html } from "../html.ts"; |
| 44 | 44 | import { fragment, page, redirect } from "../layout.ts"; |
| 45 | import { START_REFUSALS, startRefusalIn } from "../start_refusal.ts"; | |
| 45 | 46 | import { siteStructuredData } from "../structured_data.ts"; |
| 46 | 47 | import { running } from "../../kernel/version.ts"; |
| 47 | 48 | import { homeMilestones } from "./milestone_lines.ts"; |
| @@ -755,6 +756,9 @@ function agentPrompt(origin: string): string { | ||
| 755 | 756 | * controls that decide it. |
| 756 | 757 | */ |
| 757 | 758 | export function studioDoor(ctx: Ctx): Response { |
| 759 | // A refused start lands back here: one fixed sentence for why, and the name still in its field. | |
| 760 | const refusal = startRefusalIn(ctx.url.searchParams.get("start")); | |
| 761 | const keptName = refusal === undefined ? "" : (ctx.url.searchParams.get("name") ?? "").slice(0, 39); | |
| 758 | 762 | // The door draws what is in memory; a pack pushed since is picked up for the next visit. |
| 759 | 763 | void refreshCatalogQuietly(ctx.app, 5_000); |
| 760 | 764 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| @@ -793,9 +797,11 @@ export function studioDoor(ctx: Ctx): Response { | ||
| 793 | 797 | <div class="door-lead"> |
| 794 | 798 | <p class="door-support">Say what you need and an AI crew builds it in your own forge, free to try.</p> |
| 795 | 799 | <form method="post" action="/forges/new" class="door-start" id="start-forge"> |
| 800 | <input type="hidden" name="from" value="home" /> | |
| 801 | ${refusal === undefined ? null : html`<p class="error" id="start-refused">${START_REFUSALS[refusal]}</p>`} | |
| 796 | 802 | <div class="field"> |
| 797 | 803 | <label for="start-name">Forge name</label> |
| 798 | <input class="input" id="start-name" name="name" type="text" required autocomplete="off" autocapitalize="none" spellcheck="false" /> | |
| 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" /> | |
| 799 | 805 | </div> |
| 800 | 806 | ${btn("Start my forge", { variant: "primary", size: "lg" })} |
| 801 | 807 | </form> |
test/home-start-forge.test.ts +76
| @@ -5,6 +5,10 @@ import { tmpdir } from "node:os"; | ||
| 5 | 5 | import { join } from "node:path"; |
| 6 | 6 | import { createApp, type App } from "../src/app.ts"; |
| 7 | 7 | import { serve, type Servers } from "../src/http/server.ts"; |
| 8 | import { err, ok } from "../src/kernel/result.ts"; | |
| 9 | import { usePlacement, useProbe } from "../src/spawn/door.ts"; | |
| 10 | import { resetAnonSpawns } from "../src/spawn/rate.ts"; | |
| 11 | import { START_REFUSALS } from "../src/web/start_refusal.ts"; | |
| 8 | 12 | |
| 9 | 13 | let dataDir: string; |
| 10 | 14 | let app: App; |
| @@ -52,3 +56,75 @@ test("the form's POST without a same-origin header is refused by the door", asyn | ||
| 52 | 56 | }); |
| 53 | 57 | expect([400, 403]).toContain(res.status); |
| 54 | 58 | }); |
| 59 | ||
| 60 | // ---- a refused start says why on `/` (#11238) ---- | |
| 61 | ||
| 62 | let refusal: { reason: "exists" | "quota"; message: string } | undefined; | |
| 63 | const start = (name: string) => | |
| 64 | fetch(`${servers.url}/forges/new`, { | |
| 65 | method: "POST", | |
| 66 | headers: { "content-type": "application/x-www-form-urlencoded" }, | |
| 67 | body: new URLSearchParams({ name, from: "home" }), | |
| 68 | redirect: "manual", | |
| 69 | }); | |
| 70 | /** Follow the refusal to `/` the way a browser does: one redirect, then the page. */ | |
| 71 | async function landing(name: string): Promise<{ to: string; body: string }> { | |
| 72 | const res = await start(name); | |
| 73 | expect(res.status).toBe(303); | |
| 74 | const to = res.headers.get("location") ?? ""; | |
| 75 | expect(to.startsWith("/?start=")).toBe(true); | |
| 76 | return { to, body: await (await fetch(`${servers.url}${to}`)).text() }; | |
| 77 | } | |
| 78 | const sentence = (body: string) => (body.match(/id="start-refused"[^>]*>([^<]*)</)?.[1] ?? "").replaceAll("'", "'"); | |
| 79 | ||
| 80 | test("a name the door rejects lands on / with the sentence and the name kept", async () => { | |
| 81 | useProbe(app, () => async () => true); | |
| 82 | const { body } = await landing("Bad_Name"); | |
| 83 | expect(sentence(body)).toBe(START_REFUSALS.name); | |
| 84 | expect(body).toContain('value="Bad_Name"'); | |
| 85 | }); | |
| 86 | ||
| 87 | test("a taken name lands on / with the sentence and the name kept", async () => { | |
| 88 | usePlacement(app, { | |
| 89 | place: async (plan) => (refusal ? err(refusal) : ok({ url: `https://${plan.name}.fake.test`, bodyId: "x" })), | |
| 90 | retire: async () => ok(), | |
| 91 | }); | |
| 92 | refusal = { reason: "exists", message: "a child named mine already exists" }; | |
| 93 | const { body } = await landing("mine"); | |
| 94 | expect(sentence(body)).toBe(START_REFUSALS.taken); | |
| 95 | expect(body).toContain('value="mine"'); | |
| 96 | }); | |
| 97 | ||
| 98 | test("no capacity lands on / with the sentence, and no raw message", async () => { | |
| 99 | refusal = { reason: "quota", message: "7 of 7 forges sent on this node — raise the room" }; | |
| 100 | const { to, body } = await landing("roomy"); | |
| 101 | expect(sentence(body)).toBe(START_REFUSALS.full); | |
| 102 | expect(to).not.toContain("7"); | |
| 103 | expect(body).not.toContain("7 of 7"); | |
| 104 | expect(body).toContain('value="roomy"'); | |
| 105 | }); | |
| 106 | ||
| 107 | test("the rate limit lands on / with the sentence, and no code or time", async () => { | |
| 108 | refusal = undefined; | |
| 109 | resetAnonSpawns(app.db); | |
| 110 | let last = { to: "", body: "" }; | |
| 111 | for (let i = 0; i < 12; i++) { | |
| 112 | const res = await start(`kid-${i}`); | |
| 113 | if (res.status === 303) { | |
| 114 | const to = res.headers.get("location") ?? ""; | |
| 115 | last = { to, body: await (await fetch(`${servers.url}${to}`)).text() }; | |
| 116 | if (to.startsWith("/?start=rate")) break; | |
| 117 | } | |
| 118 | } | |
| 119 | expect(last.to).toStartWith("/?start=rate"); | |
| 120 | expect(sentence(last.body)).toBe(START_REFUSALS.rate); | |
| 121 | expect(last.body).not.toContain("rate_limited"); | |
| 122 | }); | |
| 123 | ||
| 124 | test("the name is escaped and an unknown kind shows nothing", async () => { | |
| 125 | const hostile = await (await fetch(`${servers.url}/?start=name&name=${encodeURIComponent('"><script>alert(1)</script>')}`)).text(); | |
| 126 | expect(hostile).not.toContain("<script>alert(1)"); | |
| 127 | const unknown = await (await fetch(`${servers.url}/?start=<b>&name=x`)).text(); | |
| 128 | expect(unknown).not.toContain('id="start-refused"'); | |
| 129 | expect(unknown).not.toContain('value="x"'); | |
| 130 | }); |
A screen reader landing on /#start-forge via the 303 reaches this input without hearing why it failed; the sentence is a loose <p>. When refusal is set, add aria-invalid="true" and aria-describedby="start-refused" here (and nothing when not refused). Add a test asserting it.