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

The account screen of a forge you just started speaks to you: no "founder", no "claim" in the address, and Enter sends the first ask #11514

Merged opened this in answer to

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

Merged

scout/forge:scout/11510-the-account-screen-of-a-forge-you-just-s → main 5 commits fork 3 threads · 3 unresolved

  • Merged. scout/11510-the-account-screen-of-a-forge-you-just-s was merged into main as b49ef64.

Review

Sign in to review this change.
19 files changed +228 −27
docs/DESIGN.md +1 −1
@@ -16,7 +16,7 @@
1616
1717 Budgets: at most three screens before the forge; the forge ready in under 20 s; the first crew activity in under 60 s; the change live within five minutes.
1818
19 Words a stranger is never shown on this path: subscription, autonomy, cell, child, claim. The list lives once, as `STRANGER_NEVER_SEES` in `src/design/first_five_minutes.ts` (with the budgets), for a test to import.
19 Words a stranger is never shown on this path: subscription, autonomy, cell, child, claim, founder, autonomous, mode. The list lives once, as `STRANGER_NEVER_SEES` in `src/design/first_five_minutes.ts` (with the budgets), for a test to import.
2020
2121 **The five rules of attention.** Every pass over a page follows them; the owner's time is the cost.
2222 Each has a real page as its pair. Enforced by rule 5: `test/attention.test.ts`.
docs/design/audit.md +1
@@ -96,6 +96,7 @@ Machine routes are not pages and have no entry: `/audit.csv`,
9696 - `/forges/new` — `pages/new-forge.ts` · send a new forge · none · colour 1.
9797 - `/family` — `pages/family.ts` · parent, this forge and children as a tree · `/forges` (the same children) · colour 1.
9898 - `/claim` — `pages/claim.ts` · claim a forge that was sent to me · none · colour 1.
99 - `/welcome` — `pages/claim.ts` (`welcomePage`) · the account screen of a forge you just started: make your account · none · none seen.
99100 - `/privacy` — `pages/privacy.ts` · what the forge keeps about a visitor, three plain lines, open to anyone · none · none seen.
100101 - `/first-admin` — `pages/first-admin.ts` · the first person becomes admin · none · none seen.
101102 - `/fleet/graph` — `pages/fleet-graph.ts` · the fleet as a graph · `/family`, `/forges` · none seen.
docs/features/home-plans.md +1 −1
@@ -12,7 +12,7 @@ Where a visitor sees what a hosted forge is and how to get one (#11139).
1212
1313 The plan's name, its price ("Free", or "$19 a month" from the plan's `priceUsd`), the machine it runs on (CPU, memory, disk) and its own sentence. A paid plan with a crew credit adds the credit after that sentence: "$5 of crew credit each month", from the plan's `creditUsd`. The words are `src/billing/plan_words.ts`, shared by both doors.
1414
15 A paid plan whose size has a [buy link](billing-links.md) also carries a "Get <plan>" link to it (on the web an anchor beside the price, in the terminal `Get <plan>: <link>`); the free plan never does, its way in is the one **Get a forge** button. When any plan carries a link, one line follows the plans on both doors: "Your forge is made after payment; an email with the link to open it arrives." (`AFTER_PAYMENT` in the same file).
15 A paid plan whose size has a [buy link](billing-links.md) also carries a "Get <plan>" link to it (on the web an anchor beside the price, in the terminal `Get <plan>: <link>`); the free plan never does, its way in is the one **Get a forge** button. When any plan carries a link, one line follows the plans on both doors: "Your forge is made after payment. The link to open it is emailed to you, usually within a few hours." (`AFTER_PAYMENT` in the same file).
1616
1717 Whether this forge takes new accounts is not said in this section: it is about getting a forge, not an account here.
1818
docs/features/welcome.md added +21
@@ -0,0 +1,21 @@
1 # The account screen of a forge you just started
2
3 Where a person who started a forge from the home arrives, and how their first ask is sent (#11510).
4
5 ## `/welcome`
6
7 The waiting page (`/forges/<name>/waiting`) sends the person to `/welcome` on their new forge, with the one-time code in the address fragment, which a server never sees. The screen says "Make your account": a username and a password, then **Done**. The person is the admin of that forge and signed in. The words say nothing of a sender, a founder or a claim; the screen is `welcomePage` and `welcomeSubmit` in `src/web/pages/claim.ts`, sharing the code check and the one-link-once rules with `/claim`, which stays for a link someone else sent.
8
9 The "Link code" field is filled and hidden by a few lines of script when the address carries the code; without JavaScript it stays visible, with a hint, so the screen still works.
10
11 Password only is deliberate: the person is minutes into a forge and has been asked for nothing else; a passkey is offered afterwards, on the first-login next steps (the everyday step). `/claim` keeps its "Use a passkey instead" button because there the new owner may be taking over from the sender.
12
13 An unknown, used or already-owned link says so in the same plain words as `/claim` and points back to the home.
14
15 ## The first ask
16
17 On a new forge's first screen the box under "What do you want built?" sends on Enter; Shift+Enter makes a newline, and so does Enter while composing text or with a modifier held (`firstAskJs`, `src/web/pages/next-steps.ts`). Without JavaScript Enter makes a newline and the **Tell the crew** button sends. No hint is printed about the key, since it would be false without the script.
18
19 ## The terminal
20
21 The terminal does not reach `/welcome`: it is the screen where the first account is made, and the terminal needs an account to sign in. Once there is one, the first ask is the terminal's own first screen (`crewFirstText`).
src/core/reserved.ts +1
@@ -133,6 +133,7 @@ export const SYSTEM_NAMES: readonly string[] = [
133133 "feed",
134134 "review",
135135 "claim",
136 "welcome",
136137 "first-admin",
137138 "forges",
138139 // the forge's own nouns, and the platform's name
src/design/first_five_minutes.ts +1 −1
@@ -6,7 +6,7 @@
66 */
77
88 /** Words never shown to a stranger on the first-five-minutes path. Closed: a change here is a change to the doc. */
9 export const STRANGER_NEVER_SEES = ["subscription", "autonomy", "cell", "child", "claim"] as const;
9 export const STRANGER_NEVER_SEES = ["subscription", "autonomy", "cell", "child", "claim", "founder", "autonomous", "mode"] as const;
1010
1111 /** The budgets, in screens and seconds. */
1212 export const FIRST_FIVE_MINUTES = {
src/http/server.ts +3 −1
@@ -275,7 +275,7 @@ import {
275275 } from "../web/studio/actions.ts";
276276 import { forgeReady } from "../web/pages/forge-ready.ts";
277277 import { forgeName } from "../web/pages/forge-name.ts";
278 import { claimFragmentPage, claimOptions, claimSubmit } from "../web/pages/claim.ts";
278 import { claimFragmentPage, claimOptions, claimSubmit, welcomePage, welcomeSubmit } from "../web/pages/claim.ts";
279279 import { firstAdminOptions, firstAdminPage, firstAdminSubmit } from "../web/pages/first-admin.ts";
280280 import { familyPage } from "../web/pages/family.ts";
281281 import { forgeLinkRevoke, forgeLinkSubmit, forgeLinkUndo, forgeRemoveConfirm, forgeRemoveSubmit, forgeStopSubmit,
@@ -847,6 +847,8 @@ export function buildRouter(): Router<Handler> {
847847 router.add("GET", "/claim", claimFragmentPage);
848848 router.add("POST", "/claim", claimSubmit);
849849 router.add("POST", "/claim/options", claimOptions);
850 router.add("GET", "/welcome", welcomePage);
851 router.add("POST", "/welcome", welcomeSubmit);
850852 // The operator's first-admin code, redeemed with a passkey (core/first_admin.ts).
851853 router.add("GET", "/first-admin", firstAdminPage);
852854 router.add("POST", "/first-admin", firstAdminSubmit);
src/web/asset.ts +1 −1
@@ -210,7 +210,7 @@ export const NO_PREFETCH_PATHS = [
210210 "/assets/*", "/api/*", "/_plat/*", "/_measure*", "/_script-error", "/mcp*", "/oauth/*", "/sw.js",
211211 "/*/*/raw/*", "/*/*/media/*", "/*/*/archive/*", "/*/*/app/*/open",
212212 "/logout*", "/signout*", "/sign-out*", "/signin*", "/sign-in*", "/login*",
213 "/claim*", "/first-admin*", "/verify*", "/go/*", "/-/*", "/apps/open*",
213 "/claim*", "/welcome*", "/first-admin*", "/verify*", "/go/*", "/-/*", "/apps/open*",
214214 "/settings/*", "/home/*", "/*/-/*",
215215 ];
216216
src/web/funnel_door.ts +1 −1
@@ -43,4 +43,4 @@ function p(o){n.sendBeacon("/_funnel",JSON.stringify(o))}
4343 var l=d.querySelector(".door-plans");
4444 if(l&&window.IntersectionObserver){var io=new IntersectionObserver(function(e){e.some(function(x){return x.isIntersecting})&&(io.disconnect(),p({step:"plans"}))});io.observe(l)}
4545 d.addEventListener("click",function(e){var t=e.target,k=t&&t.closest&&t.closest("[data-plan]");k&&p({step:"click",plan:k.getAttribute("data-plan")})},true)
46 })(navigator,document)`;
46 })(navigator,document);`;
src/web/pages/claim.ts +77 −13
@@ -93,6 +93,7 @@ function dead(
9393 state: Exclude<ClaimState["state"], "ok">,
9494 sender?: string,
9595 endedAt?: string,
96 own = false,
9697 ): Response {
9798 const ask = sender === undefined ? "whoever sent it" : sender;
9899 const says = {
@@ -122,6 +123,14 @@ function dead(
122123 text: "Someone has already made this forge theirs. If it was you, sign in.",
123124 },
124125 }[state];
126 // The person who started this forge themselves has no sender to ask.
127 const mine: Partial<Record<typeof state, string>> = {
128 unknown: "The link may have been cut short or replaced. Start the forge again from the home page.",
129 used: "Each link works once. If it was you, sign in with the account you made. Otherwise start a forge of your own from the home page.",
130 expired: "Your link stopped working. Start the forge again from the home page.",
131 revoked: "This link has ended. Start the forge again from the home page.",
132 };
133 if (own) says.text = mine[state] ?? says.text;
125134 return page(
126135 {
127136 ctx,
@@ -172,7 +181,9 @@ function claimForm(
172181 values: Values,
173182 errors: Errors,
174183 expiresAt?: string,
184 own = false,
175185 ): Html {
186 if (own) return welcomeForm(values, errors);
176187 return html`<div class="narrow">
177188 ${pageHead("This forge is yours")}
178189 <section class="settings-block">
@@ -249,6 +260,50 @@ function claimForm(
249260 </div>`;
250261 }
251262
263 /** The screen for the person who started this forge themselves: a username and a password, nothing about anyone else. */
264 function welcomeForm(values: Values, errors: Errors): Html {
265 return html`<div class="narrow">
266 ${pageHead("Make your account")}
267 <section class="settings-block">
268 <p class="mb">Choose a username and a password. This account is yours alone, and you are the admin of this forge.</p>
269 <form method="post" action="/welcome">
270 <div id="claim-token-field">
271 ${formField({
272 label: "Link code",
273 name: "token",
274 value: values.token ?? "",
275 required: true,
276 hint: "The part of your link after the #. Opening the link fills it in.",

Principle 1: the visible "Link code" field and its hint explain what the fragment script already does. The screen is meant to be a username and a password; hide the field (type=hidden) when the script fills it.

277 })}
278 </div>
279 ${formField({
280 label: "Username",
281 name: "username",
282 value: values.username ?? "",
283 autocomplete: "username",
284 required: true,
285 autofocus: true,
286 hint: "Letters, digits, dots and dashes.",
287 pattern: "[A-Za-z0-9][A-Za-z0-9._-]*",
288 ...(errors.username ? { error: errors.username } : {}),
289 })}
290 ${formField({
291 label: "Password",
292 name: "password",
293 type: "password",
294 autocomplete: "new-password",
295 minlength: MIN_SIGNUP_PASSWORD,
296 hint: `At least ${MIN_SIGNUP_PASSWORD} characters.`,
297 ...(errors.password ? { error: errors.password } : {}),
298 })}
299 <div class="form-actions">
300 <button type="submit" class="btn" data-variant="primary">Done</button>
301 </div>
302 </form>
303 </section>
304 </div>`;
305 }
306
252307 /**
253308 * The fragment link's page script: take the claim code from `#…` into the
254309 * form and drop it from the address bar, so it is never in a request line,
@@ -270,10 +325,14 @@ const fragmentJs = /* js */ `
270325
271326 // GET /claim — the form for a fragment link: the code arrives in the
272327 // browser only, so nothing is known or spent here until the form posts it.
273 export async function claimFragmentPage(ctx: Ctx): Promise<Response> {
328 export const claimFragmentPage = (ctx: Ctx): Promise<Response> => fragmentPage(ctx, false);
329 /** GET /welcome — the same, for the person who started the forge. */
330 export const welcomePage = (ctx: Ctx): Promise<Response> => fragmentPage(ctx, true);
331
332 async function fragmentPage(ctx: Ctx, own: boolean): Promise<Response> {
274333 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
275334 const spawned = await timePhaseAsync(ctx.request, "spawned", () => spawnedOrg(ctx.app));
276 if (spawned === undefined) return timePhase(ctx.request, "render", () => dead(ctx, "unknown"));
335 if (spawned === undefined) return timePhase(ctx.request, "render", () => dead(ctx, "unknown", undefined, undefined, own));
277336 const { spawner } = timePhase(ctx.request, "owners", () => owners(ctx, spawned));
278337 // The forge has one link at a time, so its state says nothing about any
279338 // particular code: a stale or made-up code only ever meets "unknown" on the POST.
@@ -282,15 +341,16 @@ export async function claimFragmentPage(ctx: Ctx): Promise<Response> {
282341 return dead(
283342 ctx,
284343 link.state === "used" ? "used" : link.revokedAt !== null ? "revoked" : "expired",
285 spawner?.username,
344 own ? undefined : spawner?.username,
286345 link.expiresAt,
346 own,
287347 );
288348 const rendering = performance.now();
289349 const response = page({
290350 ctx,
291 title: "This forge is yours",
292 body: claimForm(ctx, spawned, spawner, {}, {}, link?.expiresAt),
293 scriptJs: fragmentJs + passkeyJs,
351 title: own ? "Make your account" : "This forge is yours",
352 body: claimForm(ctx, spawned, spawner, {}, {}, link?.expiresAt, own),
353 scriptJs: own ? fragmentJs : fragmentJs + passkeyJs,
294354 });
295355 notePhase(ctx.request, "render", performance.now() - rendering);
296356 return response;
@@ -343,11 +403,15 @@ export async function claimOptions(ctx: Ctx): Promise<Response> {
343403 }
344404
345405 // POST /claim — make the account, burn the link, sign the person in.
346 export async function claimSubmit(ctx: Ctx): Promise<Response> {
406 export const claimSubmit = (ctx: Ctx): Promise<Response> => submit(ctx, false);
407 /** POST /welcome — the same, for the person who started the forge. */
408 export const welcomeSubmit = (ctx: Ctx): Promise<Response> => submit(ctx, true);
409
410 async function submit(ctx: Ctx, own: boolean): Promise<Response> {
347411 const form = await readForm(ctx);
348412 if (form instanceof Response) return form;
349413 const spawned = await spawnedOrg(ctx.app);
350 if (spawned === undefined) return dead(ctx, "unknown");
414 if (spawned === undefined) return dead(ctx, "unknown", undefined, undefined, own);
351415 const orgId = spawned.org.id;
352416 // From the form's body — never from the path or a query string, which
353417 // proxies and logs would keep.
@@ -355,7 +419,7 @@ export async function claimSubmit(ctx: Ctx): Promise<Response> {
355419 const before = readClaim(ctx.app.db, orgId, token);
356420 const { spawner } = owners(ctx, spawned);
357421 if (before.state !== "ok")
358 return dead(ctx, before.state, spawner?.username, linkOf(ctx.app.db)?.expiresAt);
422 return dead(ctx, before.state, own ? undefined : spawner?.username, linkOf(ctx.app.db)?.expiresAt, own);
359423
360424 const values = {
361425 username: field(form, "username"),
@@ -366,9 +430,9 @@ export async function claimSubmit(ctx: Ctx): Promise<Response> {
366430 page(
367431 {
368432 ctx,
369 title: "This forge is yours",
370 body: claimForm(ctx, spawned, spawner, values, e, linkOf(ctx.app.db)?.expiresAt),
371 scriptJs: passkeyJs,
433 title: own ? "Make your account" : "This forge is yours",
434 body: claimForm(ctx, spawned, spawner, values, e, linkOf(ctx.app.db)?.expiresAt, own),
435 scriptJs: own ? fragmentJs : passkeyJs,
372436 },
373437 422,
374438 );
@@ -389,7 +453,7 @@ export async function claimSubmit(ctx: Ctx): Promise<Response> {
389453
390454 // Spent first, in one statement: of two people on one link, one wins.
391455 const spent = burnClaim(ctx.app.db, orgId, token);
392 if (spent.state !== "ok") return dead(ctx, spent.state);
456 if (spent.state !== "ok") return dead(ctx, spent.state, undefined, undefined, own);
393457 const made = await createUser(ctx.app.db, {
394458 username: values.username,
395459 email: values.email.trim() === "" ? noEmail(values.username) : values.email.trim(),
src/web/pages/forge-waiting.ts +2 −2
@@ -3,7 +3,7 @@
33 * forge they just made (#11202). The page is static and holds no claim code:
44 * the code rides the fragment of this address (`#<code>`), which a server never
55 * sees. A few lines of script read it, clear it from the address bar, poll
6 * `/forges/<name>/ready` (capped), then go to the child's `/claim#<code>`.
6 * `/forges/<name>/ready` (capped), then go to the child's `/welcome#<code>`.
77 */
88 import { isChildName } from "../../core/proposal_token.ts";
99 import type { Ctx } from "../../http/context.ts";
@@ -39,7 +39,7 @@ export const WAITING_JS = /* js */ `
3939 export function forgeWaitingPage(ctx: Ctx): Response {
4040 const name = ctx.params.name ?? "";
4141 if (!isChildName(name)) return notFoundPage(ctx);
42 const claim = `${childUrlOf(ctx.app, name).replace(/\/+$/, "")}/claim`;
42 const claim = `${childUrlOf(ctx.app, name).replace(/\/+$/, "")}/welcome`;
4343 return page({
4444 ctx,
4545 title: "Making your forge",
src/web/pages/next-steps.ts +14
@@ -61,6 +61,20 @@ function stepBlock(step: NextStep, primary: boolean, body: Html | null): Html {
6161 ${!step.done && body ? html`<div class="step-body">${body}</div>` : null}`;
6262 }
6363
64 /** Enter sends the first ask; Shift+Enter (or composing text) makes a newline. */
65 export const firstAskJs = /* js */ `
66 (function () {
67 var box = document.querySelector("#composer textarea");
68 if (!box || !box.form) return;
69 box.addEventListener("keydown", function (e) {
70 if (e.key !== "Enter" || e.shiftKey || e.isComposing || e.metaKey || e.ctrlKey || e.altKey) return;
71 e.preventDefault();
72 if (box.value.trim() === "") return;
73 if (box.form.requestSubmit) box.form.requestSubmit(); else box.form.submit();
74 });
75 })();
76 `;
77
6478 /** Before the first tell: one box, "say what you want built", and one line about what pays for the crew. */
6579 function firstTell(ctx: Ctx, user: User, crew: Html[]): Html {
6680 return html`<section id="next-steps" data-first-tell>
src/web/pages/start-forge.ts +2 −2
@@ -32,7 +32,7 @@ if(!/^[a-z0-9][a-z0-9-]*$/.test(n)){say("Use lowercase letters, digits and dashe
3232 t=setTimeout(function(){fetch("/forges/name/"+encodeURIComponent(n),{credentials:"omit"}).then(function(r){
3333 return r.status===200?r.json():null}).then(function(j){
3434 if(q!==k||!j)return;say(j.verdict==="free"?n+" is free.":j.verdict==="taken"?n+" is taken. Try another.":typeof j.why==="string"?j.why:"")}).catch(function(){})},300)})
35 })(document)`;
35 })(document);`;
3636
3737 /** Waits for the forge, then goes to its claim page; after a minute and a half the link is still there. */
3838 export const startWaitJs = `(function(d){
@@ -45,7 +45,7 @@ if(j&&j.ready===true){location.replace(u);return}throw 0}).catch(function(){
4545 if(++tries>60){s.textContent=n+" is taking longer than usual. Use the link to open it.";return}
4646 setTimeout(poll,1500)})}
4747 poll()
48 })(document)`;
48 })(document);`;
4949
5050 /** A forge name the fuller form may be given: 1 to 39 lowercase letters, digits and inner dashes. */
5151 const SLUG = /^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/;
src/web/studio/home.ts +2 −2
@@ -90,7 +90,7 @@ import { sinceYouWere } from "./since.ts";
9090 import { homeTabs, unshippedAsks } from "./home_tabs.ts";
9191 import { crewKeyCard } from "./crew_key.ts";
9292 import { everydayCard } from "../pages/everyday.ts";
93 import { nextStepsCard, nextStepsOf } from "../pages/next-steps.ts";
93 import { firstAskJs, nextStepsCard, nextStepsOf } from "../pages/next-steps.ts";
9494 import { spawnedOrg } from "../../spawn/claim.ts";
9595 import { clientApps } from "./client_home.ts";
9696 import { passkeyJs } from "../passkey-client.ts";
@@ -338,7 +338,7 @@ export async function studioHome(
338338 title: "Home",
339339 body: lean,
340340 // The everyday step makes its account with a passkey (#1890).
341 ...(firstSteps && !firstTell ? { scriptJs: passkeyJs } : {}),
341 ...(firstSteps ? { scriptJs: firstTell ? firstAskJs : passkeyJs } : {}),
342342 });
343343 }
344344 // The client's first screen names its own phases (`clientApps`).
test/forge-looks.test.ts +1
@@ -48,6 +48,7 @@ const NOT_A_PAGE: Record<string, string> = {
4848 "/audit.csv": "a CSV download of /audit",
4949 "/maintenance": "no picture: the cap of 24 looks is spent; an admin's page, covered by test/maintenance.test.ts",
5050 "/claim": "answers 404 to an admin: it exists only for a link-holder",
51 "/welcome": "the account screen of a forge with no admin yet; answers 404 to an admin",
5152 "/first-admin": "the setup form for a forge with no admin yet",
5253 "/signin": "a sign-in form, not a page of the forge",
5354 "/signup": "a sign-up form, not a page of the forge",
test/home-start-forge.test.ts +1 −1
@@ -188,7 +188,7 @@ function runScript(js: string, els: Record<string, Fake>, answer: (url: string)
188188 };
189189 g["setTimeout"] = (fn: () => void) => queueMicrotask(fn);
190190 const restore = () => Object.assign(g, saved);
191 new Function("document", `return ${js.replace(/\(document\)\s*$/, "")}`)(doc)(doc);
191 new Function("document", `return ${js.replace(/\(document\);?\s*$/, "")}`)(doc)(doc);
192192 return { asked, gone, restore };
193193 }
194194 const settle = async () => {
test/stranger-three-screens.test.ts +1 −1
@@ -105,7 +105,7 @@ test("the waiting page is static and never contains the code", async () => {
105105 expect(page.status).toBe(200);
106106 const body = await page.text();
107107 expect(body).not.toContain("/claim#");
108 expect(body).toContain('data-claim="https://walker--forge.test/claim"');
108 expect(body).toContain('data-claim="https://walker--forge.test/welcome"');
109109 expect((await fetch(`${parentServers.url}/forges/Bad_Name/waiting`)).status).toBe(404);
110110 });
111111
test/stranger-words.test.ts +1
@@ -100,6 +100,7 @@ describe("a stranger's way to their own forge", () => {
100100 ["the new-forge form (/forges/new)", await get(servers.url, "/forges/new")],
101101 ["the start claim screen (/claim/start/:token)", await get(servers.url, `/claim/start/${token}`)],
102102 ["the claim screen of a new forge (/claim)", await get(fresh.base, "/claim")],
103 ["the account screen of a new forge (/welcome)", await get(fresh.base, "/welcome")],
103104 ["the plan step, leaving (/forges/new)", await plan(false)],
104105 ["the plan step, keeping access (/forges/new)", await plan(true)],
105106 ["the first-login next steps (/)", await get(claimed.base, "/", claimed.owner)],
test/welcome.test.ts added +96
@@ -0,0 +1,96 @@
1 /**
2 * The person who started a forge arrives at `/welcome` and is asked for a
3 * username and a password, nothing about a founder or a sent link (#11510);
4 * the sent link keeps `/claim`. Enter sends the first ask; the home's scripts
5 * join without a call on the one before.
6 */
7 import { afterAll, beforeAll, expect, test } from "bun:test";
8 import { firstAskJs } from "../src/web/pages/next-steps.ts";
9 import { funnelJs } from "../src/web/funnel_door.ts";
10 import { startNameJs } from "../src/web/pages/start-forge.ts";
11 import { JS_FLAG_JS } from "../src/web/claim_code.ts";
12 import { WAITING_JS } from "../src/web/pages/forge-waiting.ts";
13 import { startedForge, type StartedForge } from "./helpers/claimed-forge.ts";
14 import { quietLogs } from "./helpers/globals.ts";
15
16 quietLogs();
17
18 let fresh: StartedForge;
19 beforeAll(async () => {
20 fresh = await startedForge("forge-welcome-");
21 }, 120_000);
22 afterAll(async () => {
23 await fresh.stop();
24 });
25
26 const text = (html: string) => (/<main\b[\s\S]*?<\/main>/.exec(html)?.[0] ?? html).replace(/<(script|style)\b[\s\S]*?<\/\1>/gi, " ").replace(/<[^>]*>/g, " ");
27
28 test("the self-start screen is Make your account: username, password, done", async () => {
29 const html = await (await fetch(`${fresh.base}/welcome`)).text();
30 const words = text(html);
31 expect(words).toContain("Make your account");
32 expect(html).toContain('action="/welcome"');
33 expect(html).toContain('name="username"');
34 expect(html).toContain('name="password"');
35 expect(html).not.toContain('name="email"');
36 expect(html).not.toContain("passkey");
37 expect(words).toContain("Done");
38 for (const w of [/founder/i, /claim/i, /ask .* to send/i, /email/i]) expect(words).not.toMatch(w);
39 expect(html).not.toContain("/claim");
40 });
41
42 test("the sent link keeps /claim and its wording", async () => {
43 const html = await (await fetch(`${fresh.base}/claim`)).text();
44 expect(html).toContain('action="/claim"');
45 expect(text(html)).toContain("This forge is yours");
46 });
47
48 test("a refused welcome says nothing of a sender, and a good one signs in", async () => {
49 const post = (form: Record<string, string>) =>
50 fetch(`${fresh.base}/welcome`, { method: "POST", headers: { origin: fresh.base }, body: new URLSearchParams(form), redirect: "manual" });
51 const wrong = await post({ token: "nope", username: "maria", password: "a-long-enough-password" });
52 expect(wrong.status).toBe(404);
53 expect(text(await wrong.text())).not.toMatch(/founder|whoever sent/i);
54 const short = await post({ token: fresh.token, username: "maria", password: "x" });
55 expect(short.status).toBe(422);
56 const ok = await post({ token: fresh.token, username: "maria", password: "a-long-enough-password" });
57 expect(ok.status).toBe(303);
58 expect(ok.headers.get("location")).toBe("/");
59 expect(ok.headers.get("set-cookie")).toContain("HttpOnly");
60 const again = await post({ token: fresh.token, username: "maria2", password: "a-long-enough-password" });
61 expect(again.status).toBe(410);
62 expect(text(await again.text())).not.toMatch(/founder|whoever sent/i);
63 });
64
65 test("the waiting page goes to /welcome, not /claim", () => {
66 expect(WAITING_JS).not.toContain("/claim");
67 });
68
69 test("Enter sends the first ask, Shift+Enter makes a newline", () => {
70 const sent: string[] = [];
71 let handler: ((e: Record<string, unknown>) => void) | undefined;
72 const box = {
73 value: "a bakery menu",
74 form: { requestSubmit: () => sent.push("submit") },
75 addEventListener: (_: string, h: typeof handler) => (handler = h),
76 };
77 new Function("document", firstAskJs)({ querySelector: () => box });
78 const press = (o: Record<string, unknown>) => {
79 let prevented = false;
80 handler!({ key: "Enter", preventDefault: () => (prevented = true), ...o });
81 return prevented;
82 };
83 expect(press({})).toBe(true);
84 expect(sent).toEqual(["submit"]);
85 expect(press({ shiftKey: true })).toBe(false);
86 expect(sent).toEqual(["submit"]);
87 box.value = " ";
88 press({});
89 expect(sent).toEqual(["submit"]);
90 });
91
92 test("the home's scripts join without calling the one before", () => {
93 const joined = `${funnelJs}\n${startNameJs}\n${JS_FLAG_JS}`;
94 expect(() => new Function(joined)).not.toThrow();
95 for (const part of [funnelJs, startNameJs]) expect(part.trimEnd().endsWith(";")).toBe(true);
96 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs