Public: anyone can read this; the crew can change it.
Make my forge ends signed in on the new forge: no code to copy #8040
mason/forge:mason/2928-make-my-forge-ends-signed-in-on-the-new → main 12 commits fork 1 thread · 1 unresolved
7 files
changed +352
−1
src/http/server.ts +3
| @@ -275,6 +275,7 @@ import { familyPage } from "../web/pages/family.ts"; | ||
| 275 | 275 | import { forgeLinkRevoke, forgeLinkSubmit, forgeLinkUndo, forgeRemoveConfirm, forgeRemoveSubmit, forgeStopSubmit, forgesPage } from "../web/pages/forges.ts"; |
| 276 | 276 | import { forgeItSubmit } from "../web/pages/forge-it.ts"; |
| 277 | 277 | import { newForgePage, newForgeSubmit } from "../web/pages/new-forge.ts"; |
| 278 | import { readyAnswer, waitingPage } from "../web/pages/forge-waiting.ts"; | |
| 278 | 279 | import { askedPage } from "../web/studio/asked_page.ts"; |
| 279 | 280 | import { notesOverrule, notesSave, notesView } from "../web/pages/notes.ts"; |
| 280 | 281 | import { reviewPage } from "../web/studio/review_page.ts"; |
| @@ -846,6 +847,8 @@ export function buildRouter(): Router<Handler> { | ||
| 846 | 847 | router.add("POST", "/forges/:name/remove", forgeRemoveSubmit); |
| 847 | 848 | router.add("GET", "/forges/new", newForgePage); |
| 848 | 849 | router.add("POST", "/forges/new", newForgeSubmit); |
| 850 | router.add("GET", "/forges/:name/waiting", waitingPage); | |
| 851 | router.add("GET", "/forges/:name/ready", readyAnswer); | |
| 849 | 852 | // The front page as a stranger sees it, for anyone; a site admin there |
| 850 | 853 | // also gets the controls that decide it (#686). |
| 851 | 854 | router.add("GET", "/front", studioDoor); |
src/http/strangers.ts +17
| @@ -328,6 +328,23 @@ export function holdStream( | ||
| 328 | 328 | return { allowed: true, release }; |
| 329 | 329 | } |
| 330 | 330 | |
| 331 | /** Held `/forges/:name/ready?wait=1` asks: per caller, and every caller together. */ | |
| 332 | export const READY_HOLDS = { perCaller: 2, all: 64 } as const; | |
| 333 | ||
| 334 | /** | |
| 335 | * One held ready ask, keyed on who asks: a signed-in user by id, a stranger by | |
| 336 | * address bucket. Refused once that caller, or everyone together, holds the cap. | |
| 337 | */ | |
| 338 | export function holdReadyAsk(app: App, request: Request, userId: string | undefined): Held | Refused { | |
| 339 | const caller = userId === undefined ? `addr:${clientBucket(request)}` : `user:${userId}`; | |
| 340 | const release = holdAll(stateOf(app), [ | |
| 341 | ["ready:*", READY_HOLDS.all], | |
| 342 | [`ready:${caller}`, READY_HOLDS.perCaller], | |
| 343 | ]); | |
| 344 | if (release === undefined) return refused("too many waiting checks at once", 2); | |
| 345 | return { allowed: true, release }; | |
| 346 | } | |
| 347 | ||
| 331 | 348 | /** How many of `key` are held now — for tests. */ |
| 332 | 349 | export const heldNow = (app: App, key: string): number => |
| 333 | 350 | stateOf(app).held.get(key) ?? 0; |
src/spawn/door.ts +4
| @@ -54,6 +54,10 @@ export function useProbe(app: App, probe: (url: string) => Probe): void { | ||
| 54 | 54 | probes.set(app, probe); |
| 55 | 55 | } |
| 56 | 56 | |
| 57 | /** Whether a child's `/healthz` answers now: what the waiting page asks, by name, never by address. */ | |
| 58 | export const childLive = (app: App, name: string): Promise<boolean> => | |
| 59 | (probes.get(app) ?? probeOf)(childUrlOf(app, name))(); | |
| 60 | ||
| 57 | 61 | /** Tests (and an external placement, one day) swap where children live. */ |
| 58 | 62 | export function usePlacement(app: App, placement: Placement): void { |
| 59 | 63 | placements.set(app, placement); |
src/spawn/plan.ts +2 −1
| @@ -34,7 +34,8 @@ export const MAX_BUDGET_USD = 1000; | ||
| 34 | 34 | |
| 35 | 35 | /** One line saying what a fresh app is. */ |
| 36 | 36 | export const MAX_FRESH_CHARS = 200; |
| 37 | const NAME = /^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/; | |
| 37 | /** A child's name: lowercase letters, digits and inner dashes. */ | |
| 38 | export const NAME = /^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/; | |
| 38 | 39 | const PLAN_KEY = /^[a-z][a-z0-9-]{0,19}$/; |
| 39 | 40 | const APP_REF = /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/; |
| 40 | 41 |
src/web/pages/forge-waiting.ts added +114
| @@ -0,0 +1,114 @@ | ||
| 1 | /** | |
| 2 | * `/forges/:name/waiting`: where a stranger waits for the child they just | |
| 3 | * made. The short door answers with a 303 to `/forges/<name>/waiting#<token>`; | |
| 4 | * the token stays in the fragment, so no request line, log or Referer holds | |
| 5 | * it. The page asks `/forges/<name>/ready` (the parent asks the child's | |
| 6 | * `/healthz`, so the browser never meets a child that is still booting), | |
| 7 | * then sends the browser on to the child's `/claim#<token>`. | |
| 8 | */ | |
| 9 | ||
| 10 | import { NAME } from "../../spawn/plan.ts"; | |
| 11 | import { childLive, childUrlOf } from "../../spawn/door.ts"; | |
| 12 | import { claimLink } from "../../spawn/claim.ts"; | |
| 13 | import { holdReadyAsk, takeDoor } from "../../http/strangers.ts"; | |
| 14 | import type { App } from "../../app.ts"; | |
| 15 | import type { Ctx } from "../../http/context.ts"; | |
| 16 | import { json } from "../../http/respond.ts"; | |
| 17 | import { notFoundPage, pageHead } from "../chrome.ts"; | |
| 18 | import { html } from "../html.ts"; | |
| 19 | import { page } from "../layout.ts"; | |
| 20 | import { card } from "../ui.ts"; | |
| 21 | ||
| 22 | /** Reads the code from the fragment (32 random bytes, base64url), polls, then goes on. */ | |
| 23 | const waitingJs = /* js */ ` | |
| 24 | (function () { | |
| 25 | var box = document.getElementById('waiting'); | |
| 26 | var code = location.hash.slice(1); | |
| 27 | if (!box || !/^[A-Za-z0-9_-]{43}$/.test(code)) return; | |
| 28 | history.replaceState(null, '', location.pathname); | |
| 29 | var go = box.getAttribute('data-claim') + '#' + code; | |
| 30 | // The server holds each ask open while the child boots, so there is no timer here: | |
| 31 | // an answer, ready or not, is the cue to ask again (bounded, so a dead forge ends). | |
| 32 | var asks = 0; | |
| 33 | function poll() { | |
| 34 | if (++asks > 60) { box.textContent += ' It is taking long; reload this page to keep waiting.'; return; } | |
| 35 | fetch(box.getAttribute('data-ready') + '?wait=1', { cache: 'no-store' }) | |
| 36 | .then(function (r) { return r.json(); }) | |
| 37 | .then(function (d) { if (d.ready) location.replace(go); else poll(); }) | |
| 38 | .catch(function () { asks += 10; poll(); }); | |
| 39 | } | |
| 40 | poll(); | |
| 41 | })(); | |
| 42 | `; | |
| 43 | ||
| 44 | // GET /forges/:name/waiting — static; the code never reaches the server. | |
| 45 | export function waitingPage(ctx: Ctx): Response { | |
| 46 | const name = ctx.params["name"] ?? ""; | |
| 47 | if (!NAME.test(name)) return notFoundPage(ctx); | |
| 48 | const claim = claimLink(childUrlOf(ctx.app, name), "").replace(/#$/, ""); | |
| 49 | const response = page({ | |
| 50 | ctx, | |
| 51 | title: "Making your forge", | |
| 52 | body: html`<div class="narrow"> | |
| 53 | ${pageHead("Making your forge")} | |
| 54 | ${card( | |
| 55 | html`<p id="waiting" role="status" data-ready="/forges/${name}/ready" data-claim="${claim}"> | |
| 56 | ${name} is starting. This page opens your claim form the moment it | |
| 57 | is ready; you do not need to do anything. | |
| 58 | </p> | |
| 59 | <noscript><p class="small mt">This page needs JavaScript to read your claim code. Without it, the form answers with the claim link itself.</p></noscript>`, | |
| 60 | )} | |
| 61 | </div>`, | |
| 62 | scriptJs: waitingJs, | |
| 63 | }); | |
| 64 | response.headers.set("cache-control", "no-store"); | |
| 65 | return response; | |
| 66 | } | |
| 67 | ||
| 68 | /** How long one probe's verdict on a name stands for every caller. */ | |
| 69 | export const VERDICT_MS = 1000; | |
| 70 | ||
| 71 | /** Probe verdicts per forge, by child name: one probe a second, however many ask. */ | |
| 72 | const verdicts = new WeakMap<App, Map<string, { at: number; ready: Promise<boolean> }>>(); | |
| 73 | ||
| 74 | /** Whether the child answers, from a verdict under VERDICT_MS old when there is one. */ | |
| 75 | function liveSoon(app: App, name: string): Promise<boolean> { | |
| 76 | let byName = verdicts.get(app); | |
| 77 | if (byName === undefined) verdicts.set(app, (byName = new Map())); | |
| 78 | const now = Date.now(); | |
| 79 | const kept = byName.get(name); | |
| 80 | if (kept !== undefined && now - kept.at < VERDICT_MS) return kept.ready; | |
| 81 | for (const [n, v] of byName) if (now - v.at >= VERDICT_MS) byName.delete(n); | |
| 82 | const ready = childLive(app, name).catch(() => false); | |
| 83 | byName.set(name, { at: now, ready }); | |
| 84 | return ready; | |
| 85 | } | |
| 86 | ||
| 87 | // GET /forges/:name/ready — {ready} once the child answers. A stranger's asks | |
| 88 | // are counted per address; `?wait=1` holds the ask up to ~15 s, at most | |
| 89 | // READY_HOLDS.perCaller at once per user or address. | |
| 90 | export async function readyAnswer(ctx: Ctx): Promise<Response> { | |
| 91 | const name = ctx.params["name"] ?? ""; | |
| 92 | if (!NAME.test(name)) return json({ error: "not_found", message: "no such forge" }, 404); | |
| 93 | const user = ctx.auth.user; | |
| 94 | const tooMany = (retryAfter: number) => | |
| 95 | json({ error: "rate_limited", message: "too many checks" }, 429, { "retry-after": String(retryAfter) }); | |
| 96 | if (user === undefined) { | |
| 97 | const verdict = takeDoor(ctx.app, "api", ctx.request); | |
| 98 | if (!verdict.allowed) return tooMany(verdict.retryAfter); | |
| 99 | } | |
| 100 | const noStore = { "cache-control": "no-store" }; | |
| 101 | if (ctx.url.searchParams.get("wait") !== "1") return json({ ready: await liveSoon(ctx.app, name) }, 200, noStore); | |
| 102 | const held = holdReadyAsk(ctx.app, ctx.request, user?.id); | |
| 103 | if (!held.allowed) return tooMany(held.retryAfter); | |
| 104 | try { | |
| 105 | let ready = await liveSoon(ctx.app, name); | |
| 106 | for (let i = 0; !ready && i < 14 && !ctx.request.signal.aborted; i++) { | |
| 107 | await Bun.sleep(VERDICT_MS); | |
| 108 | ready = await liveSoon(ctx.app, name); | |
| 109 | } | |
| 110 | return json({ ready }, 200, noStore); | |
| 111 | } finally { | |
| 112 | held.release(); | |
| 113 | } | |
| 114 | } |
src/web/pages/new-forge.ts +33
| @@ -200,6 +200,7 @@ function formView( | ||
| 200 | 200 | <section class="settings-block"> |
| 201 | 201 | <h2>Its name and apps</h2> |
| 202 | 202 | <form method="post" action="/forges/new" class="stack" id="new-forge"> |
| 203 | ${anon ? html`<input type="hidden" name="js" value="" />` : null} | |
| 203 | 204 | ${formField({ |
| 204 | 205 | label: "Name", |
| 205 | 206 | name: "name", |
| @@ -472,6 +473,25 @@ function fromPack(ctx: Ctx, user: User | undefined): Partial<Values> { | ||
| 472 | 473 | return { apps, direction: pack.brief.slice(0, MAX_DIRECTION_CHARS) }; |
| 473 | 474 | } |
| 474 | 475 | |
| 476 | /** Says the browser runs scripts, so a stranger's made forge may go on to the waiting page. */ | |
| 477 | const newForgeJs = /* js */ ` | |
| 478 | (function () { | |
| 479 | var js = document.querySelector('#new-forge input[name="js"]'); | |
| 480 | if (js) js.value = '1'; | |
| 481 | })(); | |
| 482 | `; | |
| 483 | ||
| 484 | /** A claim code as a claim link carries it: 32 random bytes, base64url. */ | |
| 485 | const CLAIM_CODE = /^[A-Za-z0-9_-]{43}$/; | |
| 486 | ||
| 487 | /** The code after the claim link's `#`, or undefined when it holds none. */ | |
| 488 | export function claimCodeOf(link: string): string | undefined { | |
| 489 | const hash = link.indexOf("#"); | |
| 490 | if (hash < 0) return undefined; | |
| 491 | const code = link.slice(hash + 1); | |
| 492 | return CLAIM_CODE.test(code) ? code : undefined; | |
| 493 | } | |
| 494 | ||
| 475 | 495 | // GET /forges/new — any signed-in person, or a stranger (agents are refused). |
| 476 | 496 | export function newForgePage(ctx: Ctx): Response { |
| 477 | 497 | const user = ctx.auth.user; |
| @@ -491,6 +511,7 @@ export function newForgePage(ctx: Ctx): Response { | ||
| 491 | 511 | ctx, |
| 492 | 512 | title: "New forge", |
| 493 | 513 | body: formView(ctx, user, { ...EMPTY, code, name: (ctx.url.searchParams.get("name") ?? "").slice(0, 38), ...fromPack(ctx, user) }, errors, gift, reads), |
| 514 | scriptJs: newForgeJs, | |
| 494 | 515 | }); |
| 495 | 516 | notePhase(ctx.request, "render", performance.now() - rendering); |
| 496 | 517 | return response; |
| @@ -545,6 +566,7 @@ export async function newForgeSubmit(ctx: Ctx): Promise<Response> { | ||
| 545 | 566 | ctx, |
| 546 | 567 | title: "New forge", |
| 547 | 568 | body: formView(ctx, user, values, errorsOf(door), null, readsFor(ctx, user)), |
| 569 | scriptJs: newForgeJs, | |
| 548 | 570 | }, |
| 549 | 571 | door.status === 422 ? 422 : door.status, |
| 550 | 572 | ); |
| @@ -553,6 +575,17 @@ export async function newForgeSubmit(ctx: Ctx): Promise<Response> { | ||
| 553 | 575 | ctx, |
| 554 | 576 | title: "New forge", |
| 555 | 577 | body: formView(ctx, user, values, {}, planView(ctx, user, values, door.body), readsFor(ctx, user)), |
| 578 | scriptJs: newForgeJs, | |
| 579 | }); | |
| 580 | // A stranger whose browser runs the waiting page's script goes on to it, the | |
| 581 | // code in the fragment only. Without scripts, or without a well-formed code, | |
| 582 | // the answer below shows the claim link itself. | |
| 583 | const link = door.body["claimUrl"]; | |
| 584 | const code = user === undefined && field(form, "js") === "1" && typeof link === "string" ? claimCodeOf(link) : undefined; | |
| 585 | if (code !== undefined) | |
| 586 | return new Response(null, { | |
| 587 | status: 303, | |
| 588 | headers: { location: `/forges/${values.name}/waiting#${code}`, "cache-control": "no-store" }, | |
| 556 | 589 | }); |
| 557 | 590 | const response = page( |
| 558 | 591 | { ctx, title: "New forge", body: madeView(door.body) }, |
test/claim-redirects.test.ts added +179
| @@ -0,0 +1,179 @@ | ||
| 1 | /** | |
| 2 | * A stranger's "make my forge" ends signed in on the new forge (#2928): the | |
| 3 | * form's answer is a 303 to a waiting page with the code in the fragment, the | |
| 4 | * waiting page holds no secret and asks the parent whether the child is up, | |
| 5 | * and the child's claim form makes the first admin. The link is shown in that | |
| 6 | * one answer and stored nowhere. | |
| 7 | */ | |
| 8 | import { pageScriptOf } from "./helpers/page-script.ts"; | |
| 9 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 10 | import { mkdtemp, readdir, rm } from "node:fs/promises"; | |
| 11 | import { tmpdir } from "node:os"; | |
| 12 | import { join } from "node:path"; | |
| 13 | import { childDirOf } from "../src/actions/spawn.ts"; | |
| 14 | import { createApp, type App } from "../src/app.ts"; | |
| 15 | import { getUserByName } from "../src/core/users.ts"; | |
| 16 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 17 | import { usePlacement, useProbe } from "../src/spawn/door.ts"; | |
| 18 | import { READY_HOLDS, STRANGER_LIMITS, heldNow, setStrangerLimits } from "../src/http/strangers.ts"; | |
| 19 | import { claimCodeOf } from "../src/web/pages/new-forge.ts"; | |
| 20 | import { adminCookie } from "./helpers/admin.ts"; | |
| 21 | import { firstBoot } from "../src/spawn/first-boot.ts"; | |
| 22 | import { ok } from "../src/kernel/result.ts"; | |
| 23 | import { quietLogs } from "./helpers/globals.ts"; | |
| 24 | ||
| 25 | quietLogs(); | |
| 26 | ||
| 27 | let dir = ""; | |
| 28 | let parent: App; | |
| 29 | let parentServers: Servers; | |
| 30 | let child: App | undefined; | |
| 31 | let childServers: Servers | undefined; | |
| 32 | let live = false; | |
| 33 | ||
| 34 | beforeAll(async () => { | |
| 35 | dir = await mkdtemp(join(tmpdir(), "forge-claim-redirects-")); | |
| 36 | parent = createApp({ dataDir: dir, url: "https://forge.test", port: 0 }); | |
| 37 | useProbe(parent, () => async () => live); | |
| 38 | usePlacement(parent, { | |
| 39 | place: async (plan) => ok({ url: `https://${plan.name}.fake.test`, bodyId: "fake-wren" }), | |
| 40 | retire: async () => ok(), | |
| 41 | }); | |
| 42 | parentServers = await serve(parent); | |
| 43 | }, 30_000); | |
| 44 | ||
| 45 | afterAll(async () => { | |
| 46 | childServers?.stop(); | |
| 47 | child?.close(); | |
| 48 | parentServers.stop(); | |
| 49 | parent.close(); | |
| 50 | await rm(dir, { recursive: true, force: true }); | |
| 51 | }); | |
| 52 | ||
| 53 | test("from the form to a signed-in admin on the child, the code in the fragment only", async () => { | |
| 54 | const made = await fetch(`${parentServers.url}/forges/new`, { | |
| 55 | method: "POST", | |
| 56 | headers: { origin: "https://forge.test" }, | |
| 57 | body: new URLSearchParams({ name: "wren", do: "spawn", js: "1" }), | |
| 58 | redirect: "manual", | |
| 59 | }); | |
| 60 | expect(made.status).toBe(303); | |
| 61 | expect(made.headers.get("cache-control")).toBe("no-store"); | |
| 62 | const to = made.headers.get("location") ?? ""; | |
| 63 | expect(to).toMatch(/^\/forges\/wren\/waiting#[A-Za-z0-9_-]{43}$/); | |
| 64 | const code = to.split("#")[1]!; | |
| 65 | // In the redirect's fragment, and nowhere else: the 303 has no body. | |
| 66 | expect(await made.text()).toBe(""); | |
| 67 | const spawnDir = join(dir, "spawn"); | |
| 68 | for (const f of await readdir(spawnDir, { recursive: true }).catch(() => [] as string[])) { | |
| 69 | const text = await Bun.file(join(spawnDir, f)).text().catch(() => ""); | |
| 70 | expect(text).not.toContain(code); | |
| 71 | } | |
| 72 | ||
| 73 | // The waiting page holds no secret and says where to go on to. | |
| 74 | const waiting = await fetch(`${parentServers.url}/forges/wren/waiting`); | |
| 75 | expect(waiting.status).toBe(200); | |
| 76 | const html = await waiting.text(); | |
| 77 | expect(html).not.toContain(code); | |
| 78 | expect(html).toContain('data-ready="/forges/wren/ready"'); | |
| 79 | expect(html).toContain('data-claim="https://wren--forge.test/claim"'); | |
| 80 | expect(await pageScriptOf(parentServers.url, html)).toContain("location.hash"); | |
| 81 | ||
| 82 | // Booting: not ready. Then it answers. | |
| 83 | const ready = async () => | |
| 84 | ((await (await fetch(`${parentServers.url}/forges/wren/ready`)).json()) as { ready: boolean }).ready; | |
| 85 | expect(await ready()).toBe(false); | |
| 86 | // A held ask answers the moment the child comes up, without the client timing anything. | |
| 87 | const held = fetch(`${parentServers.url}/forges/wren/ready?wait=1`).then((r) => r.json() as Promise<{ ready: boolean }>); | |
| 88 | setTimeout(() => { live = true; }, 300); | |
| 89 | expect((await held).ready).toBe(true); | |
| 90 | live = true; | |
| 91 | expect(await ready()).toBe(true); | |
| 92 | ||
| 93 | // The claim form on the child, with the code from the fragment, makes the first admin. | |
| 94 | const bodyId = ((await Bun.file(join(spawnDir, "wren.json")).json()) as { bodyId?: string }).bodyId; | |
| 95 | if (bodyId === undefined) throw new Error("no child body"); | |
| 96 | child = createApp({ dataDir: childDirOf(parent, bodyId), port: 0 }); | |
| 97 | await firstBoot(child); | |
| 98 | childServers = await serve(child); | |
| 99 | const claimed = await fetch(`${childServers.url}/claim`, { | |
| 100 | method: "POST", | |
| 101 | headers: { origin: childServers.url }, | |
| 102 | body: new URLSearchParams({ token: code, username: "wren-owner", password: "a-long-claim-passphrase" }), | |
| 103 | redirect: "manual", | |
| 104 | }); | |
| 105 | expect(claimed.status).toBe(303); | |
| 106 | const cookie = (claimed.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 107 | expect(cookie).toContain("forge_session="); | |
| 108 | expect(getUserByName(child.db, "wren-owner")?.isAdmin).toBe(true); | |
| 109 | const home = await fetch(`${childServers.url}${claimed.headers.get("location") ?? "/"}`, { headers: { cookie } }); | |
| 110 | expect(await home.text()).toContain("wren-owner"); | |
| 111 | }); | |
| 112 | ||
| 113 | test("the ready check refuses a name that is not a child's name", async () => { | |
| 114 | expect((await fetch(`${parentServers.url}/forges/Not_A_Name/ready`)).status).toBe(404); | |
| 115 | expect((await fetch(`${parentServers.url}/forges/Not_A_Name/waiting`)).status).toBe(404); | |
| 116 | }); | |
| 117 | ||
| 118 | const spawnAs = (name: string, extra: Record<string, string>, headers: Record<string, string> = {}) => | |
| 119 | fetch(`${parentServers.url}/forges/new`, { | |
| 120 | method: "POST", | |
| 121 | headers: { origin: "https://forge.test", ...headers }, | |
| 122 | body: new URLSearchParams({ name, do: "spawn", ...extra }), | |
| 123 | redirect: "manual", | |
| 124 | }); | |
| 125 | ||
| 126 | test("the new-forge form carries the field its script sets, and the script sets it", async () => { | |
| 127 | const html = await (await fetch(`${parentServers.url}/forges/new`)).text(); | |
| 128 | expect(html).toContain('name="js" value=""'); | |
| 129 | expect(await pageScriptOf(parentServers.url, html)).toContain("js.value = '1'"); | |
| 130 | }); | |
| 131 | ||
| 132 | test("a stranger without scripts is not sent away: the answer shows the claim link", async () => { | |
| 133 | const made = await spawnAs("lark", {}); | |
| 134 | expect(made.status).toBeLessThan(300); | |
| 135 | expect(made.headers.get("location")).toBeNull(); | |
| 136 | expect(made.headers.get("cache-control")).toBe("no-store"); | |
| 137 | expect(await made.text()).toMatch(/\/claim#[A-Za-z0-9_-]{43}/); | |
| 138 | }); | |
| 139 | ||
| 140 | test("a signed-in maker gets the made page, never the waiting page", async () => { | |
| 141 | const cookie = await adminCookie(parent, { username: "ada", email: "[email protected]", password: "a-long-admin-passphrase" }); | |
| 142 | const made = await spawnAs("robin", { js: "1" }, { cookie }); | |
| 143 | expect(made.status).toBeLessThan(300); | |
| 144 | expect(made.headers.get("location")).toBeNull(); | |
| 145 | }); | |
| 146 | ||
| 147 | test("a claim code is read only from a well-formed fragment", () => { | |
| 148 | const code = "A".repeat(43); | |
| 149 | expect(claimCodeOf(`https://x.test/claim#${code}`)).toBe(code); | |
| 150 | expect(claimCodeOf("https://x.test/claim")).toBeUndefined(); | |
| 151 | expect(claimCodeOf("https://x.test/claim#short")).toBeUndefined(); | |
| 152 | expect(claimCodeOf(`https://x.test/claim#${code}/../x`)).toBeUndefined(); | |
| 153 | }); | |
| 154 | ||
| 155 | test("held ready asks are capped per caller, signed in or not", async () => { | |
| 156 | live = false; | |
| 157 | const cookie = await adminCookie(parent, { username: "bo", email: "[email protected]", password: "a-long-admin-passphrase" }); | |
| 158 | const ask = () => fetch(`${parentServers.url}/forges/kite/ready?wait=1`, { headers: { cookie } }); | |
| 159 | const first = Array.from({ length: READY_HOLDS.perCaller }, ask); | |
| 160 | while (heldNow(parent, "ready:*") < READY_HOLDS.perCaller) await Bun.sleep(10); | |
| 161 | const over = await ask(); | |
| 162 | expect(over.status).toBe(429); | |
| 163 | expect(over.headers.get("retry-after")).not.toBeNull(); | |
| 164 | live = true; | |
| 165 | for (const r of await Promise.all(first)) expect(((await r.json()) as { ready: boolean }).ready).toBe(true); | |
| 166 | expect(heldNow(parent, "ready:*")).toBe(0); | |
| 167 | }, 10_000); | |
| 168 | ||
| 169 | test("a stranger past the minute's checks gets a 429 from the ready check", async () => { | |
| 170 | setStrangerLimits(parent, { perAddress: { ...STRANGER_LIMITS.perAddress, api: 1 } }); | |
| 171 | try { | |
| 172 | expect((await fetch(`${parentServers.url}/forges/wren/ready`)).status).toBe(200); | |
| 173 | const refused = await fetch(`${parentServers.url}/forges/wren/ready`); | |
| 174 | expect(refused.status).toBe(429); | |
| 175 | expect(Number(refused.headers.get("retry-after"))).toBeGreaterThan(0); | |
| 176 | } finally { | |
| 177 | setStrangerLimits(parent, STRANGER_LIMITS); | |
| 178 | } | |
| 179 | }); |
Principles 6 and 7: a card (a box) around one status sentence. Drop card() and put the p#waiting straight under the page head; the noscript line is plain text too.