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

Merged opened this

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

Merged

scout/forge:scout/11238-a-refused-start-my-forge-says-why-on-the → main 4 commits fork 2 threads · 2 unresolved

  • Merged. scout/11238-a-refused-start-my-forge-says-why-on-the was merged into main as 81add7c.

Review

Sign in to review this change.
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
99 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`.
1010
1111 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";
3434 import { field, readForm, type Form } from "../form.ts";
3535 import { html, type Html } from "../html.ts";
3636 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";
3839 import { pickList } from "../pick.ts";
3940 import { btn, field as formField, row, selectField } from "../ui.ts";
4041
@@ -539,6 +540,11 @@ export async function newForgeSubmit(ctx: Ctx): Promise<Response> {
539540 ...(values.code === "" ? {} : { code: values.code }),
540541 dryRun: planOnly,
541542 });
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 }
542548 if (door.status >= 400)
543549 return page(
544550 {
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";
4242 import { liveRegionsJs } from "../client.ts";
4343 import { html, when, type Html } from "../html.ts";
4444 import { fragment, page, redirect } from "../layout.ts";
45 import { START_REFUSALS, startRefusalIn } from "../start_refusal.ts";
4546 import { siteStructuredData } from "../structured_data.ts";
4647 import { running } from "../../kernel/version.ts";
4748 import { homeMilestones } from "./milestone_lines.ts";
@@ -755,6 +756,9 @@ function agentPrompt(origin: string): string {
755756 * controls that decide it.
756757 */
757758 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);
758762 // The door draws what is in memory; a pack pushed since is picked up for the next visit.
759763 void refreshCatalogQuietly(ctx.app, 5_000);
760764 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
@@ -793,9 +797,11 @@ export function studioDoor(ctx: Ctx): Response {
793797 <div class="door-lead">
794798 <p class="door-support">Say what you need and an AI crew builds it in your own forge, free to try.</p>
795799 <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>`}
796802 <div class="field">
797803 <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" />
outdated

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.

799805 </div>
800806 ${btn("Start my forge", { variant: "primary", size: "lg" })}
801807 </form>
test/home-start-forge.test.ts +76
@@ -5,6 +5,10 @@ import { tmpdir } from "node:os";
55 import { join } from "node:path";
66 import { createApp, type App } from "../src/app.ts";
77 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";
812
913 let dataDir: string;
1014 let app: App;
@@ -52,3 +56,75 @@ test("the form's POST without a same-origin header is refused by the door", asyn
5256 });
5357 expect([400, 403]).toContain(res.status);
5458 });
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("&#39;", "'");
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs