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

Closed opened this
Closed without merging

mason/forge:mason/2928-make-my-forge-ends-signed-in-on-the-new → main 12 commits fork 1 thread · 1 unresolved

  • Closed without merging. Nothing was applied to main.

Review

Sign in to review this change.
7 files changed +352 −1
src/http/server.ts +3
@@ -275,6 +275,7 @@ import { familyPage } from "../web/pages/family.ts";
275275 import { forgeLinkRevoke, forgeLinkSubmit, forgeLinkUndo, forgeRemoveConfirm, forgeRemoveSubmit, forgeStopSubmit, forgesPage } from "../web/pages/forges.ts";
276276 import { forgeItSubmit } from "../web/pages/forge-it.ts";
277277 import { newForgePage, newForgeSubmit } from "../web/pages/new-forge.ts";
278 import { readyAnswer, waitingPage } from "../web/pages/forge-waiting.ts";
278279 import { askedPage } from "../web/studio/asked_page.ts";
279280 import { notesOverrule, notesSave, notesView } from "../web/pages/notes.ts";
280281 import { reviewPage } from "../web/studio/review_page.ts";
@@ -846,6 +847,8 @@ export function buildRouter(): Router<Handler> {
846847 router.add("POST", "/forges/:name/remove", forgeRemoveSubmit);
847848 router.add("GET", "/forges/new", newForgePage);
848849 router.add("POST", "/forges/new", newForgeSubmit);
850 router.add("GET", "/forges/:name/waiting", waitingPage);
851 router.add("GET", "/forges/:name/ready", readyAnswer);
849852 // The front page as a stranger sees it, for anyone; a site admin there
850853 // also gets the controls that decide it (#686).
851854 router.add("GET", "/front", studioDoor);
src/http/strangers.ts +17
@@ -328,6 +328,23 @@ export function holdStream(
328328 return { allowed: true, release };
329329 }
330330
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
331348 /** How many of `key` are held now — for tests. */
332349 export const heldNow = (app: App, key: string): number =>
333350 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 {
5454 probes.set(app, probe);
5555 }
5656
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
5761 /** Tests (and an external placement, one day) swap where children live. */
5862 export function usePlacement(app: App, placement: Placement): void {
5963 placements.set(app, placement);
src/spawn/plan.ts +2 −1
@@ -34,7 +34,8 @@ export const MAX_BUDGET_USD = 1000;
3434
3535 /** One line saying what a fresh app is. */
3636 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])?$/;
3839 const PLAN_KEY = /^[a-z][a-z0-9-]{0,19}$/;
3940 const APP_REF = /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/;
4041
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}">

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.

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(
200200 <section class="settings-block">
201201 <h2>Its name and apps</h2>
202202 <form method="post" action="/forges/new" class="stack" id="new-forge">
203 ${anon ? html`<input type="hidden" name="js" value="" />` : null}
203204 ${formField({
204205 label: "Name",
205206 name: "name",
@@ -472,6 +473,25 @@ function fromPack(ctx: Ctx, user: User | undefined): Partial<Values> {
472473 return { apps, direction: pack.brief.slice(0, MAX_DIRECTION_CHARS) };
473474 }
474475
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
475495 // GET /forges/new — any signed-in person, or a stranger (agents are refused).
476496 export function newForgePage(ctx: Ctx): Response {
477497 const user = ctx.auth.user;
@@ -491,6 +511,7 @@ export function newForgePage(ctx: Ctx): Response {
491511 ctx,
492512 title: "New forge",
493513 body: formView(ctx, user, { ...EMPTY, code, name: (ctx.url.searchParams.get("name") ?? "").slice(0, 38), ...fromPack(ctx, user) }, errors, gift, reads),
514 scriptJs: newForgeJs,
494515 });
495516 notePhase(ctx.request, "render", performance.now() - rendering);
496517 return response;
@@ -545,6 +566,7 @@ export async function newForgeSubmit(ctx: Ctx): Promise<Response> {
545566 ctx,
546567 title: "New forge",
547568 body: formView(ctx, user, values, errorsOf(door), null, readsFor(ctx, user)),
569 scriptJs: newForgeJs,
548570 },
549571 door.status === 422 ? 422 : door.status,
550572 );
@@ -553,6 +575,17 @@ export async function newForgeSubmit(ctx: Ctx): Promise<Response> {
553575 ctx,
554576 title: "New forge",
555577 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" },
556589 });
557590 const response = page(
558591 { 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs