Public: anyone can read this; the crew can change it.

No screen on a stranger's way to their own forge says subscription, autonomy, cell, child or claim: one test renders each screen and fails on the word #11198

Merged opened this

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

Merged

scout/forge:scout/11172-no-screen-on-a-stranger-s-way-to-their-o → main 7 commits fork 5 threads · 5 unresolved

  • Merged. scout/11172-no-screen-on-a-stranger-s-way-to-their-o was merged into main as 4b3f3c7.

Review

Sign in to review this change.
8 files changed +294 −121
src/spawn/door.ts +2 −2
@@ -198,13 +198,13 @@ export async function spawnDoor(
198198 const crew = names(raw["crew"], "crew", MAX_CREW);
199199 if (!Array.isArray(crew)) return crew;
200200 if (anon && crew.length > 0)
201 return invalid("crew", "anonymous spawns carry no crew; claim the forge and invite one there");
201 return invalid("crew", "anonymous spawns carry no crew; open the forge as yours and invite one there");
202202 // A stranger has no key to seal and no account to keep: the key is
203203 // refused outright, the rest simply do not apply.
204204 if (anon && raw["modelKey"] !== undefined)
205205 return invalid(
206206 "modelKey",
207 "anonymous spawns carry no model key; claim the forge and add one there",
207 "anonymous spawns carry no model key; open the forge as yours and add one there",
208208 );
209209 // The child's model key: a key or subscription token with a whole-life budget, or none. A
210210 // subscription token is a person's own plan and never travels.
src/spawn/plan.ts +1 −1
@@ -319,7 +319,7 @@ export function dryRun(plan: SpawnPlan, source: SpawnSource): DryRun {
319319 ...plan.briefs.map((b) => `crew brief for ${b.agent}`),
320320 ...plan.subscriptions.map((s) => `subscription ${s} (name only)`),
321321 ...(plan.direction === "" ? [] : ["your words, as the child's direction"]),
322 ...(plan.keepAccess ? ["your owner access, kept after the child is claimed"] : []),
322 ...(plan.keepAccess ? ["your owner access, kept after the forge is taken over"] : []),
323323 ...(plan.budgetUsd === null
324324 ? []
325325 : [`its own model key (sealed, never shown again), with a $${plan.budgetUsd} budget it cannot raise`]),
src/web/pages/claim.ts +19 −11
@@ -75,18 +75,18 @@ function dead(ctx: Ctx, state: Exclude<ClaimState["state"], "ok">): Response {
7575 const says = {
7676 unknown: {
7777 status: 404,
78 title: "This is not a claim link",
78 title: "This is not a forge link",
7979 text: "The link may have been cut short, or replaced by a newer one. Ask whoever sent it for the latest.",
8080 },
8181 used: {
8282 status: 410,
8383 title: "This link has already been used",
84 text: "Each claim link works once. If it was you, sign in with the account you made.",
84 text: "Each link works once. If it was you, sign in with the account you made.",

The 'owned' dead state a few lines below still reads 'Someone has already claimed it.' Reword in plain words, e.g. 'Someone has already made this forge theirs.' Same for the claimOptions/claimSubmit error strings that show in the passkey note.

8585 },
8686 expired: {
8787 status: 410,
8888 title: "This link has expired",
89 text: "Claim links work for a day. Ask whoever sent it for a new one.",
89 text: "Links work for a day. Ask whoever sent it for a new one.",
9090 },
9191 revoked: {
9292 status: 410,
@@ -96,7 +96,7 @@ function dead(ctx: Ctx, state: Exclude<ClaimState["state"], "ok">): Response {
9696 owned: {
9797 status: 410,
9898 title: "This forge already has an owner",
99 text: "Someone has already claimed it. If it was you, sign in.",
99 text: "Someone has already made this forge theirs. If it was you, sign in.",
100100 },
101101 }[state];
102102 return page(
@@ -139,7 +139,7 @@ function accessSentence(spawned: Spawned, spawner: User | undefined): string {
139139 const who = spawner?.username ?? "Whoever made it";
140140 return spawned.keepAccess
141141 ? `${who} keeps owner access to this forge; you can remove it in your organisation's Owners team.`
142 : `Once you claim it, only you can see its private work: ${who}, who made it, no longer has access unless you invite them back.`;
142 : `Once you open it as yours, only you can see its private work: ${who}, who made it, no longer has access unless you invite them back.`;
143143 }
144144
145145 function claimForm(
@@ -163,7 +163,7 @@ function claimForm(
163163 <form method="post" action="/claim">
164164 <div id="claim-token-field">
165165 ${formField({
166 label: "Claim code",
166 label: "Link code",
167167 name: "token",
168168 value: values.token ?? "",
169169 required: true,
@@ -201,7 +201,7 @@ function claimForm(
201201 })}
202202 ${passkeyFields()}
203203 <div class="form-actions">
204 <button type="submit" class="btn" data-variant="primary">Claim this forge</button>
204 <button type="submit" class="btn" data-variant="primary">Make this forge mine</button>
205205 </div>
206206 <div class="stack stack-tight">
207207 ${passkeyState()}
@@ -211,7 +211,7 @@ function claimForm(
211211 face or device PIN. It needs an email address above.
212212 </p>
213213 ${passkeyNote("passkey-make-note", errors.passkey)}
214 <button type="button" id="passkey-make" class="btn" data-variant="outline" data-options="/claim/options" data-needs-email>Claim with a passkey instead</button>
214 <button type="button" id="passkey-make" class="btn" data-variant="outline" data-options="/claim/options" data-needs-email>Use a passkey instead</button>
215215 </div>
216216 </div>
217217 </form>
@@ -271,6 +271,15 @@ function problems(ctx: Ctx, username: string, email: string, needEmail: boolean)
271271 return errors;
272272 }
273273
274 /** Why a passkey challenge was refused, in the passkey note's plain words. */
275 const LINK_REFUSED: Record<Exclude<ClaimState["state"], "ok">, string> = {
276 unknown: "This is not a forge link.",
277 used: "This link has already been used.",
278 expired: "This link has expired.",
279 revoked: "This link has ended.",
280 owned: "This forge already has an owner.",
281 };
282
274283 // POST /claim/options — a passkey challenge, for a live link and a fit name.
275284 // Nothing is spent: the link burns only when the key comes back.
276285 export async function claimOptions(ctx: Ctx): Promise<Response> {
@@ -283,10 +292,9 @@ export async function claimOptions(ctx: Ctx): Promise<Response> {
283292 const data = await boundedBody(ctx.request, STRANGER_MAX);
284293 if (data === "too_large") return refuse(413, "payload too large");
285294 const spawned = await spawnedOrg(ctx.app);
286 if (spawned === undefined) return refuse(404, "This is not a claim link.");
295 if (spawned === undefined) return refuse(404, LINK_REFUSED.unknown);
287296 const state = readClaim(ctx.app.db, spawned.org.id, str(data.token)).state;
288 if (state !== "ok")
289 return refuse(state === "unknown" ? 404 : 410, state === "unknown" ? "This is not a claim link." : `This link is ${state === "owned" ? "already claimed" : state}.`);
297 if (state !== "ok") return refuse(state === "unknown" ? 404 : 410, LINK_REFUSED[state]);
290298 const username = str(data.username).trim();
291299 const bad = problems(ctx, username, str(data.email).trim(), true);
292300 const first = bad.username ?? bad.email;
src/web/pages/new-forge.ts +13 −14
@@ -177,7 +177,7 @@ function formView(
177177 ${pageHead("New forge")}
178178 <section class="settings-block">
179179 <h2>No room</h2>
180 <p role="status">This forge cannot hold another child: ${full}.</p>
180 <p role="status">This forge has no room for another forge: ${full}.</p>
181181 ${ctx.auth.user?.isAdmin
182182 ? html`<p><a href="/settings/capacity">Raise the room in settings</a></p>`
183183 : null}
@@ -187,12 +187,12 @@ function formView(
187187 return html`<div class="narrow-wide" data-forges>
188188 ${pageHead("New forge", {
189189 sub: anon
190 ? "Pick a name and the apps to start from. Whoever opens the claim link becomes its admin."
190 ? "Pick a name and the apps to start from. Whoever opens the link becomes its admin."
191191 : "Pick a name and the apps to start from; you get one link to send.",
192192 actions: btn("Forges you sent", { href: "/forges", size: "sm" }),
193193 })}
194194 <p class="muted small" id="hosting-terms">Free within limits: it sleeps when idle, and its crew starts once you add your AI account.</p>
195 ${quota ? html`<p role="status" id="quota-full">This forge cannot hold another child: ${quota}.</p>` : null}
195 ${quota ? html`<p role="status" id="quota-full">This forge has no room for another forge: ${quota}.</p>` : null}
196196 ${errors["form"]
197197 ? html`<p class="error mb" role="alert">${errors["form"]}</p>`
198198 : null}
@@ -242,7 +242,7 @@ function formView(
242242 label: "Or a fresh app (optional)",
243243 name: "fresh",
244244 value: values.fresh,
245 hint: `One line on what it is; the child gets a small app to start from (at most ${MAX_FRESH_CHARS} characters).`,
245 hint: `One line on what it is; the new forge gets a small app to start from (at most ${MAX_FRESH_CHARS} characters).`,
246246 ...(errors["fresh"] ? { error: errors["fresh"] } : {}),
247247 })}
248248 <details class="disclosure" ${Object.keys(errors).some((k) => MORE_FIELDS.includes(k)) ? html`open` : null}>
@@ -288,14 +288,14 @@ function formView(
288288 ${anon
289289 ? null
290290 : html`<fieldset class="field">
291 <legend>After it is claimed</legend>
291 <legend>After it is taken over</legend>
292292 <label class="check">
293293 <input
294294 type="checkbox"
295295 name="keepAccess"
296296 ${values.keepAccess ? html`checked` : null}
297297 />
298 <span>Keep your owner access (the claim page tells them)</span>
298 <span>Keep your owner access (the page they open tells them)</span>
299299 </label>
300300 </fieldset>`}
301301 ${anon
@@ -328,25 +328,25 @@ function formView(
328328 name: "budgetUsd",
329329 type: "number",
330330 value: values.budgetUsd,
331 hint: "For the key's whole life; the child cannot raise it. Needed with a key.",
331 hint: "For the key's whole life; the new forge cannot raise it. Needed with a key.",
332332 ...(errors["budgetUsd"] ? { error: errors["budgetUsd"] } : {}),
333333 })}
334334 ${selectField({
335335 label: "Template",
336336 name: "template",
337 hint: "What the child starts from besides the apps you fork. Blank is the default.",
337 hint: "What the new forge starts from besides the apps you fork. Blank is the default.",
338338 options: templateOptions(values.template),
339339 ...(errors["template"] ? { error: errors["template"] } : {}),
340340 })}
341341 ${selectField({
342342 label: "Size (optional)",
343343 name: "size",
344 hint: "Room for the child to grow into. Small is the default and is not recorded.",
344 hint: "Room for the new forge to grow into. Small is the default and is not recorded.",
345345 options: sizeOptions(values.size),
346346 ...(errors["size"] ? { error: errors["size"] } : {}),
347347 })}
348348 <p class="muted small">
349 Never copied: ${LEFT_BEHIND.map(plainWords).join(", ")}. The child mints its own.
349 Never copied: ${LEFT_BEHIND.map(plainWords).join(", ")}. It makes its own.
350350 </p>
351351 </details>
352352 ${codeField(values.code, errors["code"])}
@@ -383,8 +383,8 @@ function planView(ctx: Ctx, user: User | undefined, values: Values, body: Record
383383 ? null
384384 : html`<li>
385385 ${values.keepAccess
386 ? "You keep owner access after it is claimed; the claim page tells them."
387 : "You leave once it is claimed; they can invite you back."}
386 ? "You keep owner access once it is taken over; the page they open tells them."
387 : "You leave once it is taken over; they can invite you back."}
outdated

Principle 8 / box 3: "once it is claimed" still uses the banned word on the new-forge flow. Reword like the line above ("once it is taken over") and add this plan step to the test screens.

388388 </li>`}
389389 ${plan?.template === undefined
390390 ? null
@@ -416,8 +416,7 @@ function giftLine(g: unknown): Html | null {
416416 return typeof gift?.plan === "string" ? html`<li><b>${giftWords({ plan: gift.plan })}</b></li>` : null;
417417 }
418418

Two stacked doc comments remain above claimLinkBody (the line above is the old one). Delete the stale one.

419 /** The answer to a spawn: the link, once. */
420 /** The claim link, shown once: what to do with it, and a way to copy it. */
419 /** The link, shown once: what to do with it, and a way to copy it. */
421420 export function claimLinkBody(link: string, note?: string): Html {
422421 return html`<p class="mb">
423422 Send this link to the person it is for. It works once, for a day, and
test/helpers/claimed-forge.ts +100 −89
@@ -39,101 +39,95 @@ export type ClaimedForge = {
3939 stop: () => Promise<void>;
4040 };
4141
42 export async function claimedForge(prefix: string): Promise<ClaimedForge> {
42 /** A forge a stranger was sent to: made by its parent, booted, not yet claimed. */
43 export type StartedForge = Omit<ClaimedForge, "owner"> & {
44 /** The one-time claim code from the link. */
45 token: string;
46 };
47
48 /** Make a parent forge, spawn a child from it and boot the child, leaving the claim to the caller. */
49 export async function startedForge(prefix: string): Promise<StartedForge> {
4350 const dir = await mkdtemp(join(tmpdir(), prefix));
44 const parent = createApp({ dataDir: dir, url: "https://forge.test", port: 0 });
45 const made = await createUser(parent.db, {
46 origin: "seed",
47 trusted: true,
48 username: "alice",
49 email: "[email protected]",
50 password: PASSWORD,
51 isAdmin: true,
52 });
53 if (!made.ok) throw new Error("user");
54 const alice = made.value;
55 const repo = await createRepo(parent, alice, { name: "bakery" });
56 if (!repo.ok) throw new Error("repo");
57 const c = await commitFiles(repoDir(parent.reposRoot, "alice", "bakery"), {
58 branch: "main",
59 message: "bakery",
60 files: [
61 { path: "forge.json", content: FORGE_JSON },
62 {
63 path: "index.ts",
64 content: "Bun.serve({fetch(){return new Response('hi')}})\n",
65 },
51 const parent = createApp({ dataDir: dir, url: "https://forge.test", port: 0 });
52 const made = await createUser(parent.db, {
53 origin: "seed",
54 trusted: true,
55 username: "alice",
56 email: "[email protected]",
57 password: PASSWORD,
58 isAdmin: true,
59 });
60 if (!made.ok) throw new Error("user");
61 const alice = made.value;
62 const repo = await createRepo(parent, alice, { name: "bakery" });
63 if (!repo.ok) throw new Error("repo");
64 const c = await commitFiles(repoDir(parent.reposRoot, "alice", "bakery"), {
65 branch: "main",
66 message: "bakery",
67 files: [
68 { path: "forge.json", content: FORGE_JSON },
69 {
70 path: "index.ts",
71 content: "Bun.serve({fetch(){return new Response('hi')}})\n",
72 },
73 ],
74 author: { name: "a", email: "[email protected]" },
75 });
76 if (!c.ok) throw new Error("commit");
77 const atlas = await createUser(parent.db, {
78 origin: "seed",
79 username: "atlas",
80 email: "[email protected]",
81 agent: true,
82 });
83 if (!atlas.ok) throw new Error("agent");
84 createAgentRow(parent.db, {
85 userId: atlas.value.id,
86 brief: "plan",
87 createdBy: alice.id,
88 });
89 addCrew(parent.db, repo.value.id, atlas.value.id, alice.id);
90 seatAs(parent.db, repo.value.id, atlas.value.id, "planner");
91 const parentServers = await serve(parent);
92 const spawned = await spawnForge(
93 parent,
94 alice,
95 {
96 name: "maria",
97 org: "marias",
98 apps: ["alice/bakery"],
99 carryBriefs: true,
100 carrySubscriptions: false,
101 direction: "Run Maria's bakery.",
102 },
103 {
104 catalog: [
105 { ref: "alice/bakery", readable: true, bytes: 10, forgeJsonBytes: 0 },
66106 ],
67 author: { name: "a", email: "[email protected]" },
68 });
69 if (!c.ok) throw new Error("commit");
70 const atlas = await createUser(parent.db, {
71 origin: "seed",
72 username: "atlas",
73 email: "[email protected]",
74 agent: true,
75 });
76 if (!atlas.ok) throw new Error("agent");
77 createAgentRow(parent.db, {
78 userId: atlas.value.id,
79 brief: "plan",
80 createdBy: alice.id,
81 });
82 addCrew(parent.db, repo.value.id, atlas.value.id, alice.id);
83 seatAs(parent.db, repo.value.id, atlas.value.id, "planner");
84 const parentServers = await serve(parent);
85 const spawned = await spawnForge(
86 parent,
87 alice,
88 {
89 name: "maria",
90 org: "marias",
91 apps: ["alice/bakery"],
92 carryBriefs: true,
93 carrySubscriptions: false,
94 direction: "Run Maria's bakery.",
95 },
96 {
97 catalog: [
98 { ref: "alice/bakery", readable: true, bytes: 10, forgeJsonBytes: 0 },
99 ],
100 briefs: [{ agent: "atlas", text: "plan" }],
101 subscriptions: [],
102 },
103 {
104 placement: hostedPlacement({ host: "forge.test" }),
105 request: new Request("https://forge.test/x", {
106 method: "POST",
107 headers: { origin: "https://forge.test" },
108 }),
109 url: new URL("https://forge.test/x"),
110 },
111 );
112 if (!spawned.ok) throw new Error(JSON.stringify(spawned.error));
113 const child = createApp({
114 dataDir: childDirOf(parent, spawned.value.bodyId),
115 port: 0,
116 });
117 await firstBoot(child);
118 const servers = await serve(child);
119 const link = spawned.value.claimUrl!;
120 const token = link.split("/claim#")[1]!;
121 const who = { token, username: "maria", email: "[email protected]" };
122 const claimed = await fetch(`${servers.url}/claim`, {
123 method: "POST",
124 headers: { origin: servers.url },
125 body: new URLSearchParams({
126 ...who,
127 ...(await passkeyFields(servers.url, "/claim/options", who)),
107 briefs: [{ agent: "atlas", text: "plan" }],
108 subscriptions: [],
109 },
110 {
111 placement: hostedPlacement({ host: "forge.test" }),
112 request: new Request("https://forge.test/x", {
113 method: "POST",
114 headers: { origin: "https://forge.test" },
128115 }),
129 redirect: "manual",
130 });
131 if (claimed.status !== 303) throw new Error(`claim ${claimed.status}`);
132 const owner = (claimed.headers.get("set-cookie") ?? "").split(";")[0]!;
116 url: new URL("https://forge.test/x"),
117 },
118 );
119 if (!spawned.ok) throw new Error(JSON.stringify(spawned.error));
120 const child = createApp({
121 dataDir: childDirOf(parent, spawned.value.bodyId),
122 port: 0,
123 });
124 await firstBoot(child);
125 const servers = await serve(child);
126 const token = spawned.value.claimUrl!.split("/claim#")[1]!;
133127 return {
134128 base: servers.url,
135129 child,
136 owner,
130 token,
137131 post: (cookie, path, form) =>
138132 fetch(`${servers.url}${path}`, {
139133 method: "POST",
@@ -150,3 +144,20 @@ export async function claimedForge(prefix: string): Promise<ClaimedForge> {
150144 },
151145 };
152146 }
147
148 /** Claim the started forge as Maria; her session cookie is `owner`. */
149 export async function claimedForge(prefix: string): Promise<ClaimedForge> {
150 const { token, ...started } = await startedForge(prefix);
151 const who = { token, username: "maria", email: "[email protected]" };
152 const claimed = await fetch(`${started.base}/claim`, {
153 method: "POST",
154 headers: { origin: started.base },
155 body: new URLSearchParams({
156 ...who,
157 ...(await passkeyFields(started.base, "/claim/options", who)),
158 }),
159 redirect: "manual",
160 });
161 if (claimed.status !== 303) throw new Error(`claim ${claimed.status}`);
162 return { ...started, owner: (claimed.headers.get("set-cookie") ?? "").split(";")[0]! };
163 }
test/new-forge.test.ts +2 −2
@@ -199,7 +199,7 @@ describe("+ New forge", () => {
199199 ).replace(/\s+/g, " ");
200200 expect(left).toContain("https://kai--forge.test");
201201 expect(left).toContain("Whoever opens the link becomes its admin");
202 expect(left).toContain("You leave once it is claimed");
202 expect(left).toContain("You leave once it is taken over");
203203 const kept = (
204204 await (
205205 await post(admin, [
@@ -347,7 +347,7 @@ describe("+ New forge", () => {
347347 usePlacement(app, hostedPlacement({ host: "forge.test", depth: 2 }));
348348 expect(await (await get(admin, "/")).text()).not.toContain('href="/forges/new"');
349349 const html = await (await get(admin, "/forges/new")).text();
350 expect(html).toContain("cannot hold another child");
350 expect(html).toContain("has no room for another forge");
351351 expect(html).not.toContain('action="/forges/new"');
352352 });
353353
test/spawn-first-boot.test.ts +2 −2
@@ -255,10 +255,10 @@ describe("a spawned child's first boot", () => {
255255 }
256256 });
257257
258 test("a made-up code says it is not a claim link", async () => {
258 test("a made-up code says it is not a forge link", async () => {
259259 const res = await claim("x".repeat(43), maria);
260260 expect(res.status).toBe(404);
261 expect(await res.text()).toContain("not a claim link");
261 expect(await res.text()).toContain("not a forge link");
262262 expect(getUserByName(child.db, "maria")).toBeUndefined();
263263 });
264264
test/stranger-words.test.ts added +155
@@ -0,0 +1,155 @@
1 /**
2 * The screens a signed-out person passes on the way to their own forge show
3 * none of the words a stranger is never shown (#11172, docs/DESIGN.md § The
4 * first five minutes). The list is `STRANGER_NEVER_SEES`, imported, not copied.
5 * Only visible text is read: not attributes, URLs or scripts.
6 */
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { startBuilding } from "../src/actions/start.ts";
12 import { createApp, type App } from "../src/app.ts";
13 import { createSession } from "../src/core/sessions.ts";
14 import { createUser } from "../src/core/users.ts";
15 import { STRANGER_NEVER_SEES } from "../src/design/first_five_minutes.ts";
16 import { restoreClaim, revokeClaim, spawnedOrg } from "../src/spawn/claim.ts";
17 import { serve, type Servers } from "../src/http/server.ts";
18 import { claimedForge, startedForge, type ClaimedForge, type StartedForge } from "./helpers/claimed-forge.ts";
19 import { quietLogs } from "./helpers/globals.ts";
20
21 quietLogs();
22
23 const BANNED = new RegExp(`(?<![A-Za-z0-9_])(?:${STRANGER_NEVER_SEES.join("|")})(?:s|ed|ing)?(?![A-Za-z0-9_])`, "gi");
24
25 /** The text a person reads in `<main>` (or the whole page when there is none), as sentences. */
26 function visibleSentences(html: string): string[] {
27 const main = /<main\b[\s\S]*?<\/main>/.exec(html)?.[0] ?? html;
28 return main
29 .replace(/<(script|style|template|svg)\b[\s\S]*?<\/\1>/gi, " ")
30 .replace(/<[^>]*>/g, " ")
31 .replace(/&amp;/g, "&").replace(/&#39;|&apos;/g, "'").replace(/&quot;/g, '"')
32 .replace(/\s+/g, " ")
33 .split(/(?<=[.!?:])\s+/)
34 .map((s) => s.trim())
35 .filter((s) => s !== "");
36 }
37
38 /** `screen: "sentence"` for every banned word on a screen. */
39 function bannedIn(screen: string, html: string): string[] {
40 return visibleSentences(html)
41 .filter((s) => new RegExp(BANNED.source, "i").test(s))
42 .map((s) => `${screen}: "${s}"`);
43 }
44
45 test("the finder names the screen and the sentence", () => {
46 expect(bannedIn("demo", "<main><p>Fine words. Your child forge.</p><a href='/claim'>ok</a></main>")).toEqual([`demo: "Your child forge."`]);
47 expect(bannedIn("demo", `<main><a href="/claim" title="claim">Open</a></main>`)).toEqual([]);
48 });
49
50 describe("a stranger's way to their own forge", () => {
51 let dir = "";
52 let app: App;
53 let servers: Servers;
54 let token = "";
55 let cookie = "";
56 let fresh: StartedForge;
57 let claimed: ClaimedForge;
58
59 beforeAll(async () => {
60 dir = await mkdtemp(join(tmpdir(), "forge-stranger-words-"));
61 app = createApp({ dataDir: dir, url: "https://forge.test", port: 0 });
62 const u = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
63 if (!u.ok) throw new Error("user");
64 cookie = `forge_session=${createSession(app.db, u.value.id).token}`;
65 servers = await serve(app);
66 const made = await startBuilding(app, { name: "words" }, "203.0.113.9", servers.url);
67 if (!made.ok) throw new Error(made.error.error);
68 token = made.value.claim.url.split("/").pop()!;
69 fresh = await startedForge("forge-stranger-fresh-");
70 claimed = await claimedForge("forge-stranger-claimed-");
71 }, 120_000);
72
73 afterAll(async () => {
74 servers.stop();
75 app.close();
76 await rm(dir, { recursive: true, force: true });
77 await fresh.stop();
78 await claimed.stop();
79 });
80
81 test("every screen is free of the banned words", async () => {
82 const get = async (base: string, path: string, cookie = "") => {
83 const r = await fetch(`${base}${path}`, { headers: cookie ? { cookie } : {} });
84 expect(r.status).toBe(200);
85 return r.text();
86 };
87 const plan = async (keepAccess: boolean) => {
88 const r = await fetch(`${servers.url}/forges/new`, {
89 method: "POST",
90 headers: { cookie, origin: "https://forge.test" },
91 body: new URLSearchParams({ name: "planned", do: "plan", fresh: "a small bakery site", ...(keepAccess ? { keepAccess: "on" } : {}) }),
92 });
93 expect(r.status).toBe(200);
94 const html = await r.text();
95 expect(html).toContain('id="plan"');
96 return html;
97 };
outdated

Only the happy-path /claim is rendered. Add the dead-link states (unknown, used, expired, owned, revoked) and a failed passkey/submit, where 'already claimed' and 'not a claim link' still show.

98 const screens: [string, string][] = [
99 ["the anonymous home (/)", await get(servers.url, "/")],
100 ["the new-forge form (/forges/new)", await get(servers.url, "/forges/new")],
101 ["the start claim screen (/claim/start/:token)", await get(servers.url, `/claim/start/${token}`)],
102 ["the claim screen of a new forge (/claim)", await get(fresh.base, "/claim")],
103 ["the plan step, leaving (/forges/new)", await plan(false)],
104 ["the plan step, keeping access (/forges/new)", await plan(true)],
105 ["the first-login next steps (/)", await get(claimed.base, "/", claimed.owner)],
106 ];
107 const found = screens.flatMap(([name, html]) => bannedIn(name, html));
108 expect(found).toEqual([]);
109 });
110
111 // Runs after the screens above: it spends the new forge's link, state by state.
112 test("the claim screen's refusals are free of the banned words", async () => {
113 const db = fresh.child.db;
114 const org = (await spawnedOrg(fresh.child))!.org.id;
115 const submit = async (status: number, form: Record<string, string>) => {
116 const r = await fetch(`${fresh.base}/claim`, { method: "POST", headers: { origin: fresh.base }, body: new URLSearchParams(form), redirect: "manual" });
117 expect(r.status).toBe(status);
118 return r.text();
119 };
120 // The passkey note shows what /claim/options says, as text.
121 const note = async (status: number, body: Record<string, string>) => {
122 const r = await fetch(`${fresh.base}/claim/options`, { method: "POST", headers: { "content-type": "application/json", origin: fresh.base }, body: JSON.stringify(body) });
123 expect(r.status).toBe(status);
124 return `<main><p>${((await r.json()) as { message: string }).message}</p></main>`;
125 };
126 const stored = () => db.query<{ value: string }, []>("SELECT value FROM user_setting WHERE key = 'spawn_claim'").get()!.value;
127 const setStored = (v: string) => db.query("UPDATE user_setting SET value = ?1 WHERE key = 'spawn_claim'").run(v);
128 const live = stored();
129 const as = (patch: Record<string, string>) => setStored(JSON.stringify({ ...JSON.parse(live), ...patch }));
130 const who = { token: fresh.token, username: "maria", email: "[email protected]" };
131 const screens: [string, string][] = [];
132 const both = async (state: string, status: number) => {
133 screens.push([`/claim, ${state}`, await submit(status, { ...who, password: "a-long-enough-password" })]);
134 screens.push([`/claim passkey note, ${state}`, await note(status, who)]);
135 };
136
137 screens.push(["/claim, unknown", await submit(404, { ...who, token: "nope", password: "a-long-enough-password" })]);
138 screens.push(["/claim passkey note, unknown", await note(404, { ...who, token: "nope" })]);
139 screens.push(["/claim, a passkey that failed", await submit(422, { ...who, challenge: "x", clientDataJSON: "x", attestationObject: "x" })]);
140 expect(revokeClaim(db, org)).toBe(true);
141 await both("revoked", 410);
142 expect(restoreClaim(db, org)).toBe(true);
143 as({ e: "2000-01-01T00:00:00.000Z" });
144 await both("expired", 410);
145 as({ u: new Date().toISOString() });
146 await both("used", 410);
147 setStored(live);
148 const admin = await createUser(db, { origin: "seed", trusted: true, username: "someone", email: "[email protected]", password: "a-good-passphrase", isAdmin: true });
149 if (!admin.ok) throw new Error("admin");
150 await both("owned", 410);
151
152 const found = screens.flatMap(([name, html]) => bannedIn(name, html));
153 expect(found).toEqual([]);
154 });
155 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs