Public: anyone can read this; the crew can change it.
A test walks a stranger from `/` to signed in on their own forge in three screens #11282
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11242-a-test-walks-a-stranger-from-to-signed-i → main 4 commits fork 3 threads · 3 unresolved
4 files
changed +206
−0
docs/features/start-my-forge.md +8
| @@ -13,3 +13,11 @@ In the terminal, a guest's "Get your own forge" command prints the plans and the | ||
| 13 | 13 | ## When the door refuses |
| 14 | 14 | |
| 15 | 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`. |
| 16 | ||
| 17 | ## Three screens, no link to copy | |
| 18 | ||
| 19 | When the door accepts a start from the home page (`from=home`, signed out), the browser is not shown the claim link. If the new forge already answers, it gets a 303 straight to its `/claim#<code>` form; if it is still booting, a "Making your forge" page polls `GET /forges/<name>/ready` and goes on by itself (`walkOn` in `src/web/pages/new-forge.ts`). On the claim form the person picks a username and password and lands signed in as the forge's admin. Screens: `/`, the claim form, the forge. Test: `test/stranger-three-screens.test.ts` (in the gate, which runs every test file). | |
| 20 | ||
| 21 | ### Why the walk is page-only | |
| 22 | ||
| 23 | A person at the terminal is already past the stranger's problem: they have an SSH login, and "New forge" spawns the forge and hands over its claim link in the same session, so there is no link to copy between screens. The three-screen walk exists for someone with only a browser, so only that door gets it. |
src/web/pages/new-forge.ts +56
| @@ -17,6 +17,8 @@ import type { Ctx } from "../../http/context.ts"; | ||
| 17 | 17 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| 18 | 18 | import { giftOf, REDEEM_WORDS } from "../../actions/codes.ts"; |
| 19 | 19 | import { quotaFull } from "../../spawn/quota.ts"; |
| 20 | import { isChildName } from "../../core/proposal_token.ts"; | |
| 21 | import { childReady } from "../../spawn/ready.ts"; | |
| 20 | 22 | import { childHost, childUrlOf, noRoom, spawnDoor, type Door } from "../../spawn/door.ts"; |
| 21 | 23 | import { |
| 22 | 24 | LEFT_BEHIND, |
| @@ -457,6 +459,57 @@ function madeView(body: Record<string, unknown>): Html { | ||
| 457 | 459 | </div>`; |
| 458 | 460 | } |
| 459 | 461 | |
| 462 | /** The script that waits for the new forge to answer, then goes on to its claim form. */ | |
| 463 | export const WAIT_JS = /* js */ ` | |
| 464 | (function () { | |
| 465 | var el = document.getElementById("making"); | |
| 466 | if (!el) return; | |
| 467 | var name = el.getAttribute("data-name"), go = document.getElementById("making-go"), tries = 0; | |
| 468 | function late() { | |
| 469 | el.textContent = "This is taking longer than usual. Press Continue to try the new forge now; if it is not up yet, come back to this page in a minute."; | |
| 470 | } | |
| 471 | function again(ms) { if (tries < 120) setTimeout(poll, ms); else late(); } | |
| 472 | function poll() { | |
| 473 | tries++; | |
| 474 | fetch("/forges/" + encodeURIComponent(name) + "/ready", { credentials: "same-origin" }) | |
| 475 | .then(function (r) { return r.json(); }) | |
| 476 | .then(function (j) { if (j && j.ready) location.replace(go.href); else again(1000); }) | |
| 477 | .catch(function () { again(2000); }); | |
| 478 | } | |
| 479 | poll(); | |
| 480 | })(); | |
| 481 | `; | |
| 482 | ||
| 483 | /** | |
| 484 | * Screen two of "Start my forge": the new forge's claim form, where the | |
| 485 | * person picks a username and password and lands signed in as its admin | |
| 486 | * (`/claim`). Answering already, the browser is sent straight there; still | |
| 487 | * booting, a waiting page polls `/forges/<name>/ready` and goes on by itself. | |
| 488 | * The code travels in the fragment of that one address and is never copied. | |
| 489 | */ | |
| 490 | async function walkOn(ctx: Ctx, body: Record<string, unknown>): Promise<Response | undefined> { | |
| 491 | const link = body["claimUrl"]; | |
| 492 | const name = String(body["name"] ?? ""); | |
| 493 | if (typeof link !== "string" || !/^https?:\/\//.test(link) || !isChildName(name)) return undefined; | |
| 494 | if ((await childReady(ctx.app, name)) === true) { | |
| 495 | const res = redirect(link); | |
| 496 | res.headers.set("cache-control", "no-store"); | |
| 497 | return res; | |
| 498 | } | |
| 499 | const response = page({ | |
| 500 | ctx, | |
| 501 | title: "Making your forge", | |
outdated
The claim link (with its code) is put in data-go and also in the noscript button, so it sits in the HTML twice. Fine with no-store, but the Continue button is only for noscript: with JavaScript on and a slow forge the person has no door. Render the button always, as an <a>, and let the script go on ahead of it. |
||
| 502 | scriptJs: WAIT_JS, | |
| 503 | body: html`<div class="narrow stack"> | |
| 504 | ${pageHead("Making your forge")} | |
| 505 | <p id="making" role="status" data-name="${name}">It will be ready in a moment; you will go on by yourself, to choose a username and a password.</p> | |
| 506 | <p>${btn("Continue", { href: link, variant: "primary", id: "making-go" })}</p> | |
| 507 | </div>`, | |
| 508 | }); | |
| 509 | response.headers.set("cache-control", "no-store"); | |
| 510 | return response; | |
| 511 | } | |
| 512 | ||
| 460 | 513 | const asBool = (form: Form, name: string): boolean => |
| 461 | 514 | field(form, name) === "on"; |
| 462 | 515 | |
| @@ -561,6 +614,9 @@ export async function newForgeSubmit(ctx: Ctx): Promise<Response> { | ||
| 561 | 614 | body: formView(ctx, user, values, {}, planView(ctx, user, values, door.body), readsFor(ctx, user)), |
| 562 | 615 | }); |
| 563 | 616 | noteForgeMade(ctx); |
| 617 | // A stranger from the home page is not handed a link: the browser goes on to the claim form itself (#11242). | |
| 618 | const going = user === undefined && field(form, "from") === "home" ? await walkOn(ctx, door.body) : undefined; | |
| 619 | if (going !== undefined) return going; | |
| 564 | 620 | const response = page( |
| 565 | 621 | { ctx, title: "New forge", body: madeView(door.body) }, |
| 566 | 622 | door.status, |
test/home-start-forge.test.ts +1
| @@ -106,6 +106,7 @@ test("no capacity lands on / with the sentence, and no raw message", async () => | ||
| 106 | 106 | |
| 107 | 107 | test("the rate limit lands on / with the sentence, and no code or time", async () => { |
| 108 | 108 | refusal = undefined; |
| 109 | useProbe(app, () => async () => false); // every forge started here is still booting: a 200 waiting page, then the 303 refusal | |
| 109 | 110 | resetAnonSpawns(app.db); |
| 110 | 111 | let last = { to: "", body: "" }; |
| 111 | 112 | for (let i = 0; i < 12; i++) { |
test/stranger-three-screens.test.ts added +141
| @@ -0,0 +1,141 @@ | ||
| 1 | /** | |
| 2 | * The stranger's walk (#11242, acceptance of #11153): with no cookies, from `/` | |
| 3 | * to signed in on their own forge in at most three screens — the home page, | |
| 4 | * the new forge's claim form, the forge itself — and no link to copy. The | |
| 5 | * parent and the child it spawns both run for real; only the placement's | |
| 6 | * address is a stand-in, so each hop is followed by hand the way a browser does. | |
| 7 | */ | |
| 8 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 9 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { childDirOf, childNamed } from "../src/actions/spawn.ts"; | |
| 13 | import { createApp, type App } from "../src/app.ts"; | |
| 14 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 15 | import { WAIT_JS } from "../src/web/pages/new-forge.ts"; | |
| 16 | import { useProbe } from "../src/spawn/door.ts"; | |
| 17 | import { firstBoot } from "../src/spawn/first-boot.ts"; | |
| 18 | import { getUserByName } from "../src/core/users.ts"; | |
| 19 | import { quietLogs } from "./helpers/globals.ts"; | |
| 20 | ||
| 21 | quietLogs(); | |
| 22 | ||
| 23 | let dir = ""; | |
| 24 | let parent: App; | |
| 25 | let parentServers: Servers; | |
| 26 | let child: App | undefined; | |
| 27 | let childServers: Servers | undefined; | |
| 28 | let up = false; | |
| 29 | ||
| 30 | beforeAll(async () => { | |
| 31 | dir = await mkdtemp(join(tmpdir(), "forge-three-")); | |
| 32 | parent = createApp({ dataDir: dir, url: "https://forge.test", port: 0 }); | |
| 33 | useProbe(parent, () => async () => up); | |
| 34 | parentServers = await serve(parent); | |
| 35 | }); | |
| 36 | afterAll(async () => { | |
| 37 | childServers?.stop(); | |
| 38 | child?.close(); | |
| 39 | parentServers.stop(); | |
| 40 | parent.close(); | |
| 41 | await rm(dir, { recursive: true, force: true }); | |
| 42 | }); | |
| 43 | ||
| 44 | const start = (name: string) => | |
| 45 | fetch(`${parentServers.url}/forges/new`, { | |
| 46 | method: "POST", | |
| 47 | headers: { "content-type": "application/x-www-form-urlencoded" }, | |
| 48 | body: new URLSearchParams({ name, from: "home" }), | |
| 49 | redirect: "manual", | |
| 50 | }); | |
| 51 | ||
| 52 | test("screen 1 to 2: a forge still booting shows a waiting page that goes on by itself, with no link to copy", async () => { | |
| 53 | up = false; | |
| 54 | const home = await (await fetch(`${parentServers.url}/`)).text(); | |
| 55 | expect(home).toContain('id="start-forge"'); | |
| 56 | const res = await start("walker"); | |
| 57 | expect(res.status).toBe(200); | |
| 58 | expect(res.headers.get("cache-control")).toBe("no-store"); | |
| 59 | const body = await res.text(); | |
| 60 | expect(body).toContain('id="making"'); | |
| 61 | expect(body).toContain('data-name="walker"'); | |
| 62 | expect(body).not.toContain("The link to send"); | |
| 63 | expect(body).not.toContain("data-copy"); | |
| 64 | // The only way on is one Continue link, always rendered (not only without JavaScript), to the claim form. | |
| 65 | const claims = body.match(/href="https:\/\/walker--forge\.test\/claim#[A-Za-z0-9_-]{43}"/g) ?? []; | |
| 66 | expect(claims).toHaveLength(1); | |
| 67 | expect(body).toMatch(/<a[^>]*id="making-go"[^>]*>\s*Continue/); | |
| 68 | expect(body).not.toContain("<noscript>"); | |
| 69 | }); | |
| 70 | ||
| 71 | /** Run the waiting script against stubs; `answers` is what each poll gets. */ | |
| 72 | function runWait(answers: Array<"pending" | "down">) { | |
| 73 | const status = { textContent: "", getAttribute: () => "walker" }; | |
| 74 | const go = { href: "https://walker--forge.test/claim#x" }; | |
| 75 | const replaced: string[] = []; | |
| 76 | let timers = 0; | |
| 77 | let n = 0; | |
| 78 | const fetchStub = () => { | |
| 79 | const a = answers[Math.min(n++, answers.length - 1)]; | |
| 80 | return a === "down" ? Promise.reject(new Error("net")) : Promise.resolve({ json: async () => ({ ready: false }) }); | |
| 81 | }; | |
| 82 | const doc = { getElementById: (id: string) => (id === "making" ? status : go) }; | |
| 83 | const timeout = (f: () => void) => { timers++; queueMicrotask(f); return 0; }; | |
| 84 | new Function("document", "fetch", "setTimeout", "location", WAIT_JS)(doc, fetchStub, timeout, { replace: (u: string) => replaced.push(u) }); | |
| 85 | return { status, replaced, polls: () => n, timers: () => timers }; | |
| 86 | } | |
| 87 | const settle = () => new Promise((r) => setTimeout(r, 50)); | |
| 88 | ||
| 89 | test("the waiting script gives up after 120 tries and says so in plain words, never going on by itself", async () => { | |
| 90 | const w = runWait(["pending"]); | |
| 91 | await settle(); | |
| 92 | expect(w.polls()).toBe(120); | |
| 93 | expect(w.status.textContent).toContain("taking longer than usual"); | |
| 94 | expect(w.status.textContent).toContain("Continue"); | |
| 95 | expect(w.replaced).toEqual([]); | |
| 96 | }); | |
| 97 | ||
| 98 | test("a network error keeps trying and says the same when it gives up", async () => { | |
| 99 | const w = runWait(["down"]); | |
| 100 | await settle(); | |
| 101 | expect(w.polls()).toBe(120); | |
| 102 | expect(w.status.textContent).toContain("taking longer than usual"); | |
| 103 | }); | |
| 104 | ||
| 105 | test("a name the door refuses still comes back to `/`, not to the waiting page", async () => { | |
| 106 | const res = await start("Bad_Name"); | |
| 107 | expect(res.status).toBe(303); | |
| 108 | expect(res.headers.get("location") ?? "").toStartWith("/?start="); | |
| 109 | }); | |
| 110 | ||
| 111 | test("screen 2 to 3: a forge that answers sends the browser to its claim form; a username and password land signed in as its admin", async () => { | |
| 112 | up = true; | |
| 113 | const res = await start("runner"); | |
| 114 | expect(res.status).toBe(303); | |
| 115 | const to = res.headers.get("location") ?? ""; | |
| 116 | expect(to).toMatch(/^https:\/\/runner--forge\.test\/claim#[A-Za-z0-9_-]{43}$/); | |
| 117 | expect(res.headers.get("cache-control")).toBe("no-store"); | |
| 118 | const token = to.split("#")[1] ?? ""; | |
| 119 | ||
| 120 | const made = await childNamed(parent, "runner"); | |
| 121 | if (made === undefined) throw new Error("no child"); | |
| 122 | child = createApp({ dataDir: childDirOf(parent, made.bodyId), port: 0 }); | |
| 123 | await firstBoot(child); | |
| 124 | childServers = await serve(child); | |
| 125 | ||
| 126 | const form = await fetch(`${childServers.url}/claim`); | |
| 127 | expect(form.status).toBe(200); | |
| 128 | const claimed = await fetch(`${childServers.url}/claim`, { | |
| 129 | method: "POST", | |
| 130 | headers: { origin: childServers.url }, | |
| 131 | body: new URLSearchParams({ token, username: "walker-owner", password: "a-long-walker-passphrase" }), | |
| 132 | redirect: "manual", | |
| 133 | }); | |
| 134 | expect(claimed.status).toBe(303); | |
| 135 | const cookie = (claimed.headers.get("set-cookie") ?? "").split(";")[0] ?? ""; | |
| 136 | expect(cookie).not.toBe(""); | |
| 137 | expect(getUserByName(child.db, "walker-owner")?.isAdmin).toBe(true); | |
| 138 | const landed = await fetch(`${childServers.url}${claimed.headers.get("location") ?? "/"}`, { headers: { cookie } }); | |
| 139 | expect(landed.status).toBe(200); | |
| 140 | expect(await landed.text()).toContain("walker-owner"); | |
| 141 | }); |
Principle 8 (plain words) and usability: after 120 tries the poll silently stops. Show a sentence and a way on (button to the claim form, which is the working link) when it gives up, and on a network error.