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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11172-no-screen-on-a-stranger-s-way-to-their-o → main 7 commits fork 5 threads · 5 unresolved
src/spawn/door.ts +2 −2
| @@ -198,13 +198,13 @@ export async function spawnDoor( | ||
| 198 | 198 | const crew = names(raw["crew"], "crew", MAX_CREW); |
| 199 | 199 | if (!Array.isArray(crew)) return crew; |
| 200 | 200 | 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"); | |
| 202 | 202 | // A stranger has no key to seal and no account to keep: the key is |
| 203 | 203 | // refused outright, the rest simply do not apply. |
| 204 | 204 | if (anon && raw["modelKey"] !== undefined) |
| 205 | 205 | return invalid( |
| 206 | 206 | "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", | |
| 208 | 208 | ); |
| 209 | 209 | // The child's model key: a key or subscription token with a whole-life budget, or none. A |
| 210 | 210 | // 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 { | ||
| 319 | 319 | ...plan.briefs.map((b) => `crew brief for ${b.agent}`), |
| 320 | 320 | ...plan.subscriptions.map((s) => `subscription ${s} (name only)`), |
| 321 | 321 | ...(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"] : []), | |
| 323 | 323 | ...(plan.budgetUsd === null |
| 324 | 324 | ? [] |
| 325 | 325 | : [`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 { | ||
| 75 | 75 | const says = { |
| 76 | 76 | unknown: { |
| 77 | 77 | status: 404, |
| 78 | title: "This is not a claim link", | |
| 78 | title: "This is not a forge link", | |
| 79 | 79 | text: "The link may have been cut short, or replaced by a newer one. Ask whoever sent it for the latest.", |
| 80 | 80 | }, |
| 81 | 81 | used: { |
| 82 | 82 | status: 410, |
| 83 | 83 | 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.", | |
| 85 | 85 | }, |
| 86 | 86 | expired: { |
| 87 | 87 | status: 410, |
| 88 | 88 | 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.", | |
| 90 | 90 | }, |
| 91 | 91 | revoked: { |
| 92 | 92 | status: 410, |
| @@ -96,7 +96,7 @@ function dead(ctx: Ctx, state: Exclude<ClaimState["state"], "ok">): Response { | ||
| 96 | 96 | owned: { |
| 97 | 97 | status: 410, |
| 98 | 98 | 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.", | |
| 100 | 100 | }, |
| 101 | 101 | }[state]; |
| 102 | 102 | return page( |
| @@ -139,7 +139,7 @@ function accessSentence(spawned: Spawned, spawner: User | undefined): string { | ||
| 139 | 139 | const who = spawner?.username ?? "Whoever made it"; |
| 140 | 140 | return spawned.keepAccess |
| 141 | 141 | ? `${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.`; | |
| 143 | 143 | } |
| 144 | 144 | |
| 145 | 145 | function claimForm( |
| @@ -163,7 +163,7 @@ function claimForm( | ||
| 163 | 163 | <form method="post" action="/claim"> |
| 164 | 164 | <div id="claim-token-field"> |
| 165 | 165 | ${formField({ |
| 166 | label: "Claim code", | |
| 166 | label: "Link code", | |
| 167 | 167 | name: "token", |
| 168 | 168 | value: values.token ?? "", |
| 169 | 169 | required: true, |
| @@ -201,7 +201,7 @@ function claimForm( | ||
| 201 | 201 | })} |
| 202 | 202 | ${passkeyFields()} |
| 203 | 203 | <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> | |
| 205 | 205 | </div> |
| 206 | 206 | <div class="stack stack-tight"> |
| 207 | 207 | ${passkeyState()} |
| @@ -211,7 +211,7 @@ function claimForm( | ||
| 211 | 211 | face or device PIN. It needs an email address above. |
| 212 | 212 | </p> |
| 213 | 213 | ${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> | |
| 215 | 215 | </div> |
| 216 | 216 | </div> |
| 217 | 217 | </form> |
| @@ -271,6 +271,15 @@ function problems(ctx: Ctx, username: string, email: string, needEmail: boolean) | ||
| 271 | 271 | return errors; |
| 272 | 272 | } |
| 273 | 273 | |
| 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 | ||
| 274 | 283 | // POST /claim/options — a passkey challenge, for a live link and a fit name. |
| 275 | 284 | // Nothing is spent: the link burns only when the key comes back. |
| 276 | 285 | export async function claimOptions(ctx: Ctx): Promise<Response> { |
| @@ -283,10 +292,9 @@ export async function claimOptions(ctx: Ctx): Promise<Response> { | ||
| 283 | 292 | const data = await boundedBody(ctx.request, STRANGER_MAX); |
| 284 | 293 | if (data === "too_large") return refuse(413, "payload too large"); |
| 285 | 294 | 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); | |
| 287 | 296 | 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]); | |
| 290 | 298 | const username = str(data.username).trim(); |
| 291 | 299 | const bad = problems(ctx, username, str(data.email).trim(), true); |
| 292 | 300 | const first = bad.username ?? bad.email; |
src/web/pages/new-forge.ts +13 −14
| @@ -177,7 +177,7 @@ function formView( | ||
| 177 | 177 | ${pageHead("New forge")} |
| 178 | 178 | <section class="settings-block"> |
| 179 | 179 | <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> | |
| 181 | 181 | ${ctx.auth.user?.isAdmin |
| 182 | 182 | ? html`<p><a href="/settings/capacity">Raise the room in settings</a></p>` |
| 183 | 183 | : null} |
| @@ -187,12 +187,12 @@ function formView( | ||
| 187 | 187 | return html`<div class="narrow-wide" data-forges> |
| 188 | 188 | ${pageHead("New forge", { |
| 189 | 189 | 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." | |
| 191 | 191 | : "Pick a name and the apps to start from; you get one link to send.", |
| 192 | 192 | actions: btn("Forges you sent", { href: "/forges", size: "sm" }), |
| 193 | 193 | })} |
| 194 | 194 | <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} | |
| 196 | 196 | ${errors["form"] |
| 197 | 197 | ? html`<p class="error mb" role="alert">${errors["form"]}</p>` |
| 198 | 198 | : null} |
| @@ -242,7 +242,7 @@ function formView( | ||
| 242 | 242 | label: "Or a fresh app (optional)", |
| 243 | 243 | name: "fresh", |
| 244 | 244 | 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).`, | |
| 246 | 246 | ...(errors["fresh"] ? { error: errors["fresh"] } : {}), |
| 247 | 247 | })} |
| 248 | 248 | <details class="disclosure" ${Object.keys(errors).some((k) => MORE_FIELDS.includes(k)) ? html`open` : null}> |
| @@ -288,14 +288,14 @@ function formView( | ||
| 288 | 288 | ${anon |
| 289 | 289 | ? null |
| 290 | 290 | : html`<fieldset class="field"> |
| 291 | <legend>After it is claimed</legend> | |
| 291 | <legend>After it is taken over</legend> | |
| 292 | 292 | <label class="check"> |
| 293 | 293 | <input |
| 294 | 294 | type="checkbox" |
| 295 | 295 | name="keepAccess" |
| 296 | 296 | ${values.keepAccess ? html`checked` : null} |
| 297 | 297 | /> |
| 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> | |
| 299 | 299 | </label> |
| 300 | 300 | </fieldset>`} |
| 301 | 301 | ${anon |
| @@ -328,25 +328,25 @@ function formView( | ||
| 328 | 328 | name: "budgetUsd", |
| 329 | 329 | type: "number", |
| 330 | 330 | 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.", | |
| 332 | 332 | ...(errors["budgetUsd"] ? { error: errors["budgetUsd"] } : {}), |
| 333 | 333 | })} |
| 334 | 334 | ${selectField({ |
| 335 | 335 | label: "Template", |
| 336 | 336 | 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.", | |
| 338 | 338 | options: templateOptions(values.template), |
| 339 | 339 | ...(errors["template"] ? { error: errors["template"] } : {}), |
| 340 | 340 | })} |
| 341 | 341 | ${selectField({ |
| 342 | 342 | label: "Size (optional)", |
| 343 | 343 | 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.", | |
| 345 | 345 | options: sizeOptions(values.size), |
| 346 | 346 | ...(errors["size"] ? { error: errors["size"] } : {}), |
| 347 | 347 | })} |
| 348 | 348 | <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. | |
| 350 | 350 | </p> |
| 351 | 351 | </details> |
| 352 | 352 | ${codeField(values.code, errors["code"])} |
| @@ -383,8 +383,8 @@ function planView(ctx: Ctx, user: User | undefined, values: Values, body: Record | ||
| 383 | 383 | ? null |
| 384 | 384 | : html`<li> |
| 385 | 385 | ${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. |
||
| 388 | 388 | </li>`} |
| 389 | 389 | ${plan?.template === undefined |
| 390 | 390 | ? null |
| @@ -416,8 +416,7 @@ function giftLine(g: unknown): Html | null { | ||
| 416 | 416 | return typeof gift?.plan === "string" ? html`<li><b>${giftWords({ plan: gift.plan })}</b></li>` : null; |
| 417 | 417 | } |
| 418 | 418 | |
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. */ | |
| 421 | 420 | export function claimLinkBody(link: string, note?: string): Html { |
| 422 | 421 | return html`<p class="mb"> |
| 423 | 422 | 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 = { | ||
| 39 | 39 | stop: () => Promise<void>; |
| 40 | 40 | }; |
| 41 | 41 | |
| 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> { | |
| 43 | 50 | 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 }, | |
| 66 | 106 | ], |
| 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" }, | |
| 128 | 115 | }), |
| 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]!; | |
| 133 | 127 | return { |
| 134 | 128 | base: servers.url, |
| 135 | 129 | child, |
| 136 | owner, | |
| 130 | token, | |
| 137 | 131 | post: (cookie, path, form) => |
| 138 | 132 | fetch(`${servers.url}${path}`, { |
| 139 | 133 | method: "POST", |
| @@ -150,3 +144,20 @@ export async function claimedForge(prefix: string): Promise<ClaimedForge> { | ||
| 150 | 144 | }, |
| 151 | 145 | }; |
| 152 | 146 | } |
| 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", () => { | ||
| 199 | 199 | ).replace(/\s+/g, " "); |
| 200 | 200 | expect(left).toContain("https://kai--forge.test"); |
| 201 | 201 | 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"); | |
| 203 | 203 | const kept = ( |
| 204 | 204 | await ( |
| 205 | 205 | await post(admin, [ |
| @@ -347,7 +347,7 @@ describe("+ New forge", () => { | ||
| 347 | 347 | usePlacement(app, hostedPlacement({ host: "forge.test", depth: 2 })); |
| 348 | 348 | expect(await (await get(admin, "/")).text()).not.toContain('href="/forges/new"'); |
| 349 | 349 | 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"); | |
| 351 | 351 | expect(html).not.toContain('action="/forges/new"'); |
| 352 | 352 | }); |
| 353 | 353 |
test/spawn-first-boot.test.ts +2 −2
| @@ -255,10 +255,10 @@ describe("a spawned child's first boot", () => { | ||
| 255 | 255 | } |
| 256 | 256 | }); |
| 257 | 257 | |
| 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 () => { | |
| 259 | 259 | const res = await claim("x".repeat(43), maria); |
| 260 | 260 | 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"); | |
| 262 | 262 | expect(getUserByName(child.db, "maria")).toBeUndefined(); |
| 263 | 263 | }); |
| 264 | 264 |
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(/&/g, "&").replace(/'|'/g, "'").replace(/"/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 | }); |
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.