Public: anyone can read this; the crew can change it.
Make my forge takes a stranger with JavaScript to the claim form already filled in; without JavaScript the answer still shows the link once #11316
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/11202-make-my-forge-takes-a-stranger-with-java → main 10 commits fork
8 files
changed +204
−95
docs/features/claim-redirect.md added +20
| @@ -0,0 +1,20 @@ | ||
| 1 | # Claim redirect | |
| 2 | ||
| 3 | What a stranger sees after pressing Start my forge (on `/`, `POST /forges/start`) | |
| 4 | or Spawn on `/forges/new` (`POST /forges/new`). Both forms carry `data-js`, and | |
| 5 | one script (`JS_FLAG_JS`, in `src/web/claim_code.ts`) adds `js=1` to them. | |
| 6 | ||
| 7 | - **With JavaScript.** The answer is a 303 to `/forges/<name>/waiting#<code>` | |
| 8 | (`no-store`, its address carries the code). The waiting page is static and | |
| 9 | never holds the code: a few lines of script read the fragment, clear it from | |
| 10 | the address bar, poll `/forges/<name>/ready` (at most 120 tries), then go to | |
| 11 | the child's `/claim#<code>`. No code to copy. | |
| 12 | - **Without JavaScript, from `/`.** No `js` field: the answer is a page, "Setting | |
| 13 | up <name>", with one link to the claim form (`no-store`). | |
| 14 | - **Without JavaScript, from `/forges/new`.** The answer is a 201 page that shows | |
| 15 | the claim link once (`no-store`), to send or open. | |
| 16 | - **From the terminal.** Unchanged: the door returns the claim link once and | |
| 17 | the terminal prints it. | |
| 18 | ||
| 19 | A signed-in person never gets the redirect. A claim code that is not well formed | |
| 20 | is never put in an address: the link page shows it instead. |
src/http/server.ts +2
| @@ -281,6 +281,7 @@ import { forgeLinkRevoke, forgeLinkSubmit, forgeLinkUndo, forgeRemoveConfirm, fo | ||
| 281 | 281 | forgeOwnCodeSubmit, forgesPage } from "../web/pages/forges.ts"; |
| 282 | 282 | import { forgeItSubmit } from "../web/pages/forge-it.ts"; |
| 283 | 283 | import { funnelSubmit } from "./funnel.ts"; |
| 284 | import { forgeWaitingPage } from "../web/pages/forge-waiting.ts"; | |
| 284 | 285 | import { startForgeSubmit } from "../web/pages/start-forge.ts"; |
| 285 | 286 | import { newForgePage, newForgeSubmit } from "../web/pages/new-forge.ts"; |
| 286 | 287 | import { askedPage } from "../web/studio/asked_page.ts"; |
| @@ -857,6 +858,7 @@ export function buildRouter(): Router<Handler> { | ||
| 857 | 858 | router.add("GET", "/forges/:name/remove", forgeRemoveConfirm); |
| 858 | 859 | router.add("POST", "/forges/:name/remove", forgeRemoveSubmit); |
| 859 | 860 | router.add("GET", "/forges/:name/ready", forgeReady); |
| 861 | router.add("GET", "/forges/:name/waiting", forgeWaitingPage); | |
| 860 | 862 | router.add("GET", "/forges/new", newForgePage); |
| 861 | 863 | router.add("POST", "/forges/new", newForgeSubmit); |
| 862 | 864 | router.add("POST", "/forges/start", startForgeSubmit); |
src/web/claim_code.ts added +34
| @@ -0,0 +1,34 @@ | ||
| 1 | /** | |
| 2 | * The claim code a new forge hands its first person (#11202): what it looks | |
| 3 | * like, the address of the waiting page that carries it in the fragment, and | |
| 4 | * the script that tells the server a browser ran script. | |
| 5 | */ | |
| 6 | import { isChildName } from "../core/proposal_token.ts"; | |
| 7 | ||
| 8 | /** What the claim code looks like: 32 random bytes, base64url. */ | |
| 9 | export const CLAIM_CODE = /^[A-Za-z0-9_-]{43}$/; | |
| 10 | ||
| 11 | /** | |
| 12 | * Sets `js=1` on the forms that ask for it (`data-js`), so the answer can tell | |
| 13 | * a browser that ran this script (sent to the waiting page, the code in the | |
| 14 | * fragment) from one that did not (shown the link once). | |
| 15 | */ | |
| 16 | export const JS_FLAG_JS = /* js */ ` | |
| 17 | (function () { | |
| 18 | var forms = document.querySelectorAll("form[data-js]"); | |
| 19 | for (var i = 0; i < forms.length; i++) { | |
| 20 | var f = document.createElement("input"); | |
| 21 | f.type = "hidden"; f.name = "js"; f.value = "1"; | |
| 22 | forms[i].appendChild(f); | |
| 23 | } | |
| 24 | })(); | |
| 25 | `; | |
| 26 | ||
| 27 | /** The waiting page for a new forge, the claim code in the fragment only (never sent to a server). */ | |
| 28 | export function waitingAddress(body: Record<string, unknown>): string | undefined { | |
| 29 | const link = body["claimUrl"]; | |
| 30 | const name = String(body["name"] ?? ""); | |
| 31 | if (typeof link !== "string" || !isChildName(name)) return undefined; | |
| 32 | const code = link.split("#")[1] ?? ""; | |
| 33 | return CLAIM_CODE.test(code) ? `/forges/${name}/waiting#${code}` : undefined; | |
| 34 | } |
src/web/pages/forge-waiting.ts added +54
| @@ -0,0 +1,54 @@ | ||
| 1 | /** | |
| 2 | * `GET /forges/<name>/waiting`: where a stranger with JavaScript waits for the | |
| 3 | * forge they just made (#11202). The page is static and holds no claim code: | |
| 4 | * the code rides the fragment of this address (`#<code>`), which a server never | |
| 5 | * sees. A few lines of script read it, clear it from the address bar, poll | |
| 6 | * `/forges/<name>/ready` (capped), then go to the child's `/claim#<code>`. | |
| 7 | */ | |
| 8 | import { isChildName } from "../../core/proposal_token.ts"; | |
| 9 | import type { Ctx } from "../../http/context.ts"; | |
| 10 | import { childUrlOf } from "../../spawn/door.ts"; | |
| 11 | import { notFoundPage, pageHead } from "../chrome.ts"; | |
| 12 | import { html } from "../html.ts"; | |
| 13 | import { page } from "../layout.ts"; | |
| 14 | import { CLAIM_CODE } from "../claim_code.ts"; | |
| 15 | ||
| 16 | export const WAITING_JS = /* js */ ` | |
| 17 | (function () { | |
| 18 | var el = document.getElementById("waiting"); | |
| 19 | if (!el) return; | |
| 20 | var code = location.hash.slice(1), tries = 0; | |
| 21 | if (!${CLAIM_CODE}.test(code)) { el.textContent = "This link has no claim code. Start again from the new forge page."; return; } | |
| 22 | history.replaceState(null, "", location.pathname); | |
| 23 | var name = el.getAttribute("data-name"), base = el.getAttribute("data-claim"); | |
| 24 | function again(ms) { | |
| 25 | if (tries < 120) setTimeout(poll, ms); | |
| 26 | else el.textContent = "This is taking longer than usual. Reload this page in a minute."; | |
| 27 | } | |
| 28 | function poll() { | |
| 29 | tries++; | |
| 30 | fetch("/forges/" + encodeURIComponent(name) + "/ready", { credentials: "same-origin" }) | |
| 31 | .then(function (r) { return r.json(); }) | |
| 32 | .then(function (j) { if (j && j.ready) location.replace(base + "#" + code); else again(1000); }) | |
| 33 | .catch(function () { again(2000); }); | |
| 34 | } | |
| 35 | poll(); | |
| 36 | })(); | |
| 37 | `; | |
| 38 | ||
| 39 | export function forgeWaitingPage(ctx: Ctx): Response { | |
| 40 | const name = ctx.params.name ?? ""; | |
| 41 | if (!isChildName(name)) return notFoundPage(ctx); | |
| 42 | const claim = `${childUrlOf(ctx.app, name).replace(/\/+$/, "")}/claim`; | |
| 43 | return page({ | |
| 44 | ctx, | |
| 45 | title: "Making your forge", | |
| 46 | scriptJs: WAITING_JS, | |
| 47 | body: html`<div class="narrow stack"> | |
| 48 | ${pageHead("Making your forge")} | |
| 49 | <p id="waiting" role="status" data-name="${name}" data-claim="${claim}">It will be ready in a moment.</p> | |
| 50 | <p>You will then choose a username and a password.</p> | |
| 51 | <noscript><p>This page needs JavaScript. Go back and make the forge again to get its link.</p></noscript> | |
| 52 | </div>`, | |
| 53 | }); | |
| 54 | } |
src/web/pages/new-forge.ts +9 −67
| @@ -17,8 +17,6 @@ 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"; | |
| 22 | 20 | import { childHost, childUrlOf, noRoom, spawnDoor, type Door } from "../../spawn/door.ts"; |
| 23 | 21 | import { |
| 24 | 22 | LEFT_BEHIND, |
| @@ -37,7 +35,7 @@ import { field, readForm, type Form } from "../form.ts"; | ||
| 37 | 35 | import { html, type Html } from "../html.ts"; |
| 38 | 36 | import { noteForgeMade } from "../funnel_door.ts"; |
| 39 | 37 | import { page, redirect } from "../layout.ts"; |
| 40 | import { startRefusalHref, startRefusalOf } from "../start_refusal.ts"; | |
| 38 | import { JS_FLAG_JS, waitingAddress } from "../claim_code.ts"; | |
| 41 | 39 | import { pickList } from "../pick.ts"; |
| 42 | 40 | import { btn, field as formField, row, selectField } from "../ui.ts"; |
| 43 | 41 | |
| @@ -209,7 +207,7 @@ function formView( | ||
| 209 | 207 | ${plan} |
| 210 | 208 | <section class="settings-block"> |
| 211 | 209 | <h2>Its name and apps</h2> |
| 212 | <form method="post" action="/forges/new" class="stack" id="new-forge"> | |
| 210 | <form method="post" action="/forges/new" class="stack" id="new-forge" data-js> | |
| 213 | 211 | ${formField({ |
| 214 | 212 | label: "Name", |
| 215 | 213 | name: "name", |
| @@ -473,57 +471,6 @@ function madeView(body: Record<string, unknown>, isAdmin: boolean): Html { | ||
| 473 | 471 | </div>`; |
| 474 | 472 | } |
| 475 | 473 | |
| 476 | /** The script that waits for the new forge to answer, then goes on to its claim form. */ | |
| 477 | export const WAIT_JS = /* js */ ` | |
| 478 | (function () { | |
| 479 | var el = document.getElementById("making"); | |
| 480 | if (!el) return; | |
| 481 | var name = el.getAttribute("data-name"), go = document.getElementById("making-go"), tries = 0; | |
| 482 | function late() { | |
| 483 | 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."; | |
| 484 | } | |
| 485 | function again(ms) { if (tries < 120) setTimeout(poll, ms); else late(); } | |
| 486 | function poll() { | |
| 487 | tries++; | |
| 488 | fetch("/forges/" + encodeURIComponent(name) + "/ready", { credentials: "same-origin" }) | |
| 489 | .then(function (r) { return r.json(); }) | |
| 490 | .then(function (j) { if (j && j.ready) location.replace(go.href); else again(1000); }) | |
| 491 | .catch(function () { again(2000); }); | |
| 492 | } | |
| 493 | poll(); | |
| 494 | })(); | |
| 495 | `; | |
| 496 | ||
| 497 | /** | |
| 498 | * Screen two of "Start my forge": the new forge's claim form, where the | |
| 499 | * person picks a username and password and lands signed in as its admin | |
| 500 | * (`/claim`). Answering already, the browser is sent straight there; still | |
| 501 | * booting, a waiting page polls `/forges/<name>/ready` and goes on by itself. | |
| 502 | * The code travels in the fragment of that one address and is never copied. | |
| 503 | */ | |
| 504 | async function walkOn(ctx: Ctx, body: Record<string, unknown>): Promise<Response | undefined> { | |
| 505 | const link = body["claimUrl"]; | |
| 506 | const name = String(body["name"] ?? ""); | |
| 507 | if (typeof link !== "string" || !/^https?:\/\//.test(link) || !isChildName(name)) return undefined; | |
| 508 | if ((await childReady(ctx.app, name)) === true) { | |
| 509 | const res = redirect(link); | |
| 510 | res.headers.set("cache-control", "no-store"); | |
| 511 | return res; | |
| 512 | } | |
| 513 | const response = page({ | |
| 514 | ctx, | |
| 515 | title: "Making your forge", | |
| 516 | scriptJs: WAIT_JS, | |
| 517 | body: html`<div class="narrow stack"> | |
| 518 | ${pageHead("Making your forge")} | |
| 519 | <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> | |
| 520 | <p>${btn("Continue", { href: link, variant: "primary", id: "making-go" })}</p> | |
| 521 | </div>`, | |
| 522 | }); | |
| 523 | response.headers.set("cache-control", "no-store"); | |
| 524 | return response; | |
| 525 | } | |
| 526 | ||
| 527 | 474 | const asBool = (form: Form, name: string): boolean => |
| 528 | 475 | field(form, name) === "on"; |
| 529 | 476 | |
| @@ -558,6 +505,7 @@ export function newForgePage(ctx: Ctx): Response { | ||
| 558 | 505 | const response = page({ |
| 559 | 506 | ctx, |
| 560 | 507 | title: "New forge", |
| 508 | ...(user === undefined ? { scriptJs: JS_FLAG_JS } : {}), | |
| 561 | 509 | body: formView(ctx, user, { ...EMPTY, code, name: (ctx.url.searchParams.get("name") ?? "").slice(0, 38), ...fromPack(ctx, user) }, errors, gift, reads), |
| 562 | 510 | }); |
| 563 | 511 | notePhase(ctx.request, "render", performance.now() - rendering); |
| @@ -607,11 +555,6 @@ export async function newForgeSubmit(ctx: Ctx): Promise<Response> { | ||
| 607 | 555 | ...(values.code === "" ? {} : { code: values.code }), |
| 608 | 556 | dryRun: planOnly, |
| 609 | 557 | }); |
| 610 | // The home page's own form says it came from there: a refusal goes back to `/` with the name kept. | |
| 611 | if (door.status >= 400 && user === undefined && field(form, "from") === "home" && door.status !== 401 && door.status !== 403) { | |
| 612 | const kind = startRefusalOf(door); | |
| 613 | return redirect(startRefusalHref(kind, values.name)); | |
| 614 | } | |
| 615 | 558 | if (door.status >= 400) |
| 616 | 559 | return page( |
| 617 | 560 | { |
| @@ -628,13 +571,12 @@ export async function newForgeSubmit(ctx: Ctx): Promise<Response> { | ||
| 628 | 571 | body: formView(ctx, user, values, {}, planView(ctx, user, values, door.body), readsFor(ctx, user)), |
| 629 | 572 | }); |
| 630 | 573 | noteForgeMade(ctx); |
| 631 | // A stranger from the home page is not handed a link: the browser goes on to the claim form itself (#11242). | |
| 632 | const going = user === undefined && field(form, "from") === "home" ? await walkOn(ctx, door.body) : undefined; | |
| 633 | if (going !== undefined) return going; | |
| 634 | const response = page( | |
| 635 | { ctx, title: "New forge", body: madeView(door.body, ctx.auth.user?.isAdmin === true) }, | |
| 636 | door.status, | |
| 637 | ); | |
| 574 | // A stranger whose script ran is not handed a link: the waiting page carries the code in its fragment (#11202). | |
| 575 | const waiting = user === undefined && field(form, "js") === "1" ? waitingAddress(door.body) : undefined; | |
| 576 | const response = | |
| 577 | waiting !== undefined | |
| 578 | ? redirect(waiting) | |
| 579 | : page({ ctx, title: "New forge", body: madeView(door.body, ctx.auth.user?.isAdmin === true) }, door.status); | |
| 638 | 580 | // The link is in this body only: never kept by a cache. |
| 639 | 581 | response.headers.set("cache-control", "no-store"); |
| 640 | 582 | if (values.code !== "") response.headers.append("set-cookie", dropGift(ctx.url, ctx.app.config.url)); |
src/web/pages/start-forge.ts +9
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | * then goes straight to the new forge's claim page (username and password, |
| 7 | 7 | * signed in on landing). The claim link is in this one no-store body, as the |
| 8 | 8 | * target of a link and a redirect: never shown as text, never to copy. |
| 9 | * With JavaScript the answer is a 303 to `/forges/<name>/waiting#<code>` instead. | |
| 9 | 10 | * A signed-in person is sent to the fuller `/forges/new`, which keeps its form. |
| 10 | 11 | */ |
| 11 | 12 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; |
| @@ -16,6 +17,7 @@ import { field, readForm } from "../form.ts"; | ||
| 16 | 17 | import { html, type Html } from "../html.ts"; |
| 17 | 18 | import { page, redirect } from "../layout.ts"; |
| 18 | 19 | import { notFoundPage, pageHead } from "../chrome.ts"; |
| 20 | import { waitingAddress } from "../claim_code.ts"; | |
| 19 | 21 | import { startRefusalHref, startRefusalOf } from "../start_refusal.ts"; |
| 20 | 22 | |
| 21 | 23 | /** The home's live name check: 404 from the ready probe means nothing by that name was made here. */ |
| @@ -73,6 +75,13 @@ export async function startForgeSubmit(ctx: Ctx): Promise<Response> { | ||
| 73 | 75 | const link = door.body["claimUrl"]; |
| 74 | 76 | if (typeof link !== "string") return redirect(startRefusalHref("taken", name)); |
| 75 | 77 | noteForgeMade(ctx); |
| 78 | // A browser that ran the page's script goes to the static waiting page, the code in the fragment only (#11202). | |
| 79 | const waiting = field(form, "js") === "1" ? waitingAddress(door.body) : undefined; | |
| 80 | if (waiting !== undefined) { | |
| 81 | const to = redirect(waiting); | |
| 82 | to.headers.set("cache-control", "no-store"); | |
| 83 | return to; | |
| 84 | } | |
| 76 | 85 | const body = startWaitBody(name, link); |
| 77 | 86 | const response = page({ ctx, title: "Setting up…", body, scriptJs: startWaitJs }); |
| 78 | 87 | response.headers.set("cache-control", "no-store"); |
src/web/studio/home.ts +3 −2
| @@ -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 { JS_FLAG_JS } from "../claim_code.ts"; | |
| 31 | 32 | import { startNameJs } from "../pages/start-forge.ts"; |
| 32 | 33 | import { GET_A_FORGE_PATH } from "../../billing/plan_words.ts"; |
| 33 | 34 | import { appsForRepos } from "../../core/apps.ts"; |
| @@ -797,7 +798,7 @@ export function studioDoor(ctx: Ctx): Response { | ||
| 797 | 798 | <div class="door-under"> |
| 798 | 799 | <div class="door-lead"> |
| 799 | 800 | <p class="door-support">Say what you need and an AI crew builds it in your own forge, free to try.</p> |
| 800 | <form method="post" action="/forges/start" class="door-start" id="start-forge"> | |
| 801 | <form method="post" action="/forges/start" class="door-start" id="start-forge" data-js> | |
| 801 | 802 | ${refusal === undefined ? null : html`<p class="error" id="start-refused">${START_REFUSALS[refusal]}</p>`} |
| 802 | 803 | <div class="field"> |
| 803 | 804 | <label for="start-name">Forge name</label> |
| @@ -928,7 +929,7 @@ bun run src/main.ts</code></pre> | ||
| 928 | 929 | title: "forge: software for what you're building, yours to keep", |
| 929 | 930 | description: "Your own forge, with a private AI crew that builds your apps, games and business tools. Open source, and yours to keep.", |
| 930 | 931 | body, |
| 931 | scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${funnelJs}\n${startNameJs}`, | |
| 932 | scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${funnelJs}\n${startNameJs}\n${JS_FLAG_JS}`, | |
| 932 | 933 | jsonLd: siteStructuredData(origin, running().version), |
| 933 | 934 | ...(visitor |
| 934 | 935 | ? { |
test/stranger-three-screens.test.ts +73 −26
| @@ -12,7 +12,8 @@ import { join } from "node:path"; | ||
| 12 | 12 | import { childDirOf, childNamed } from "../src/actions/spawn.ts"; |
| 13 | 13 | import { createApp, type App } from "../src/app.ts"; |
| 14 | 14 | import { serve, type Servers } from "../src/http/server.ts"; |
| 15 | import { WAIT_JS } from "../src/web/pages/new-forge.ts"; | |
| 15 | import { JS_FLAG_JS } from "../src/web/claim_code.ts"; | |
| 16 | import { WAITING_JS } from "../src/web/pages/forge-waiting.ts"; | |
| 16 | 17 | import { useProbe } from "../src/spawn/door.ts"; |
| 17 | 18 | import { firstBoot } from "../src/spawn/first-boot.ts"; |
| 18 | 19 | import { getUserByName } from "../src/core/users.ts"; |
| @@ -41,48 +42,89 @@ afterAll(async () => { | ||
| 41 | 42 | await rm(dir, { recursive: true, force: true }); |
| 42 | 43 | }); |
| 43 | 44 | |
| 44 | const start = (name: string) => | |
| 45 | fetch(`${parentServers.url}/forges/new`, { | |
| 45 | const start = (name: string, js = true, path = "/forges/start") => | |
| 46 | fetch(`${parentServers.url}${path}`, { | |
| 46 | 47 | method: "POST", |
| 47 | 48 | headers: { "content-type": "application/x-www-form-urlencoded" }, |
| 48 | body: new URLSearchParams({ name, from: "home" }), | |
| 49 | body: new URLSearchParams(js ? { name, js: "1" } : { name }), | |
| 49 | 50 | redirect: "manual", |
| 50 | 51 | }); |
| 51 | 52 | |
| 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 | test("screen 1 to 2: with JavaScript the answer is a 303 to the waiting page, the code only in the fragment", async () => { | |
| 53 | 54 | up = false; |
| 54 | 55 | const home = await (await fetch(`${parentServers.url}/`)).text(); |
| 55 | 56 | expect(home).toContain('id="start-forge"'); |
| 56 | 57 | const res = await start("walker"); |
| 57 | expect(res.status).toBe(200); | |
| 58 | expect(res.status).toBe(303); | |
| 59 | expect(res.headers.get("cache-control")).toBe("no-store"); | |
| 60 | expect(res.headers.get("location") ?? "").toMatch(/^\/forges\/walker\/waiting#[A-Za-z0-9_-]{43}$/); | |
| 61 | }); | |
| 62 | ||
| 63 | /** The text of every script a page loads from its own address. */ | |
| 64 | async function pageScripts(html: string): Promise<string> { | |
| 65 | const srcs = [...html.matchAll(/<script src="([^"]+)"/g)].map((m) => m[1]!); | |
| 66 | return (await Promise.all(srcs.map(async (u) => (await fetch(`${parentServers.url}${u}`)).text()))).join("\n"); | |
| 67 | } | |
| 68 | ||
| 69 | test("the home form and /forges/new both carry data-js and load the script that adds js=1", async () => { | |
| 70 | const home = await (await fetch(`${parentServers.url}/`)).text(); | |
| 71 | expect(home).toMatch(/<form[^>]*id="start-forge"[^>]*data-js/); | |
| 72 | expect(await pageScripts(home)).toContain("form[data-js]"); | |
| 73 | const form = await (await fetch(`${parentServers.url}/forges/new`)).text(); | |
| 74 | expect(form).toMatch(/<form[^>]*id="new-forge"[^>]*data-js/); | |
| 75 | expect(await pageScripts(form)).toContain("form[data-js]"); | |
| 76 | }); | |
| 77 | ||
| 78 | test("the flag script appends a hidden js=1 input to each data-js form", () => { | |
| 79 | const added: Array<{ type?: string; name?: string; value?: string }> = []; | |
| 80 | const form = { appendChild: (e: { type?: string; name?: string; value?: string }) => added.push(e) }; | |
| 81 | const doc = { querySelectorAll: () => [form], createElement: () => ({}) }; | |
| 82 | new Function("document", JS_FLAG_JS)(doc); | |
| 83 | expect(added).toEqual([{ type: "hidden", name: "js", value: "1" }]); | |
| 84 | }); | |
| 85 | ||
| 86 | test("a claim code that is not well formed never goes in an address", async () => { | |
| 87 | const { waitingAddress } = await import("../src/web/claim_code.ts"); | |
| 88 | expect(waitingAddress({ name: "walker", claimUrl: "https://walker--forge.test/claim#short" })).toBeUndefined(); | |
| 89 | expect(waitingAddress({ name: "Bad_Name", claimUrl: `https://x/claim#${"a".repeat(43)}` })).toBeUndefined(); | |
| 90 | expect(waitingAddress({ name: "walker", claimUrl: `https://x/claim#${"a".repeat(43)}` })).toBe(`/forges/walker/waiting#${"a".repeat(43)}`); | |
| 91 | }); | |
| 92 | ||
| 93 | test("without JavaScript, /forges/new answers with the page (201) that shows the claim link once", async () => { | |
| 94 | const res = await start("plainwalker", false, "/forges/new"); | |
| 95 | expect(res.status).toBe(201); | |
| 96 | expect(res.headers.get("location")).toBeNull(); | |
| 58 | 97 | expect(res.headers.get("cache-control")).toBe("no-store"); |
| 59 | 98 | 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>"); | |
| 99 | expect(body).toContain("The link to send"); | |
| 100 | expect(body).toMatch(/plainwalker--forge\.test\/claim#[A-Za-z0-9_-]{43}/); | |
| 101 | }); | |
| 102 | ||
| 103 | test("the waiting page is static and never contains the code", async () => { | |
| 104 | const page = await fetch(`${parentServers.url}/forges/walker/waiting`); | |
| 105 | expect(page.status).toBe(200); | |
| 106 | const body = await page.text(); | |
| 107 | expect(body).not.toContain("/claim#"); | |
| 108 | expect(body).toContain('data-claim="https://walker--forge.test/claim"'); | |
| 109 | expect((await fetch(`${parentServers.url}/forges/Bad_Name/waiting`)).status).toBe(404); | |
| 69 | 110 | }); |
| 70 | 111 | |
| 71 | 112 | /** 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" }; | |
| 113 | function runWait(answers: Array<"pending" | "down" | "ready">) { | |
| 114 | const code = "a".repeat(43); | |
| 115 | const status = { textContent: "", getAttribute: (k: string) => (k === "data-name" ? "walker" : "https://walker--forge.test/claim") }; | |
| 75 | 116 | const replaced: string[] = []; |
| 117 | const cleared: string[] = []; | |
| 76 | 118 | let timers = 0; |
| 77 | 119 | let n = 0; |
| 78 | 120 | const fetchStub = () => { |
| 79 | 121 | const a = answers[Math.min(n++, answers.length - 1)]; |
| 80 | return a === "down" ? Promise.reject(new Error("net")) : Promise.resolve({ json: async () => ({ ready: false }) }); | |
| 122 | return a === "down" ? Promise.reject(new Error("net")) : Promise.resolve({ json: async () => ({ ready: a === "ready" }) }); | |
| 81 | 123 | }; |
| 82 | const doc = { getElementById: (id: string) => (id === "making" ? status : go) }; | |
| 124 | const doc = { getElementById: () => status }; | |
| 83 | 125 | 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 }; | |
| 126 | new Function("document", "fetch", "setTimeout", "location", "history", WAITING_JS)(doc, fetchStub, timeout, { hash: `#${code}`, pathname: "/forges/walker/waiting", replace: (u: string) => replaced.push(u) }, { replaceState: (_a: unknown, _b: string, u: string) => cleared.push(u) }); | |
| 127 | return { status, replaced, cleared, polls: () => n, timers: () => timers }; | |
| 86 | 128 | } |
| 87 | 129 | const settle = () => new Promise((r) => setTimeout(r, 50)); |
| 88 | 130 | |
| @@ -91,7 +133,7 @@ test("the waiting script gives up after 120 tries and says so in plain words, ne | ||
| 91 | 133 | await settle(); |
| 92 | 134 | expect(w.polls()).toBe(120); |
| 93 | 135 | expect(w.status.textContent).toContain("taking longer than usual"); |
| 94 | expect(w.status.textContent).toContain("Continue"); | |
| 136 | expect(w.cleared).toEqual(["/forges/walker/waiting"]); | |
| 95 | 137 | expect(w.replaced).toEqual([]); |
| 96 | 138 | }); |
| 97 | 139 | |
| @@ -102,19 +144,24 @@ test("a network error keeps trying and says the same when it gives up", async () | ||
| 102 | 144 | expect(w.status.textContent).toContain("taking longer than usual"); |
| 103 | 145 | }); |
| 104 | 146 | |
| 147 | test("the waiting script goes to the child's claim form with the code once the forge answers", async () => { | |
| 148 | const w = runWait(["ready"]); | |
| 149 | await settle(); | |
| 150 | expect(w.replaced).toEqual([`https://walker--forge.test/claim#${"a".repeat(43)}`]); | |
| 151 | }); | |
| 152 | ||
| 105 | 153 | test("a name the door refuses still comes back to `/`, not to the waiting page", async () => { |
| 106 | 154 | const res = await start("Bad_Name"); |
| 107 | 155 | expect(res.status).toBe(303); |
| 108 | 156 | expect(res.headers.get("location") ?? "").toStartWith("/?start="); |
| 109 | 157 | }); |
| 110 | 158 | |
| 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 () => { | |
| 159 | test("screen 2 to 3: the waiting page hands on to the claim form; a username and password land signed in as its admin", async () => { | |
| 112 | 160 | up = true; |
| 113 | 161 | const res = await start("runner"); |
| 114 | 162 | expect(res.status).toBe(303); |
| 115 | 163 | 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"); | |
| 164 | expect(to).toMatch(/^\/forges\/runner\/waiting#[A-Za-z0-9_-]{43}$/); | |
| 118 | 165 | const token = to.split("#")[1] ?? ""; |
| 119 | 166 | |
| 120 | 167 | const made = await childNamed(parent, "runner"); |
new above
offline