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

A stranger's first five minutes: five rough edges found by a private-tab and… #11950

Merged opened this in answer to

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

Merged

torque/forge:torque/11948-a-stranger-s-first-five-minutes-five-rou → main 7 commits fork 7 threads · 7 unresolved

  • Merged. torque/11948-a-stranger-s-first-five-minutes-five-rou was merged into main as 481ba05.

Review

Sign in to review this change.
15 files changed +338 −21
docs/features/claim-own-name.md added +19
@@ -0,0 +1,19 @@
1 # Taking the forge's own name
2
3 A forge made from a sent link or Start my forge has an organisation named after
4 it (`probe`). A person claiming it may choose that same name for their account:
5 the organisation row becomes the account (a person, an admin, with the password
6 or passkey given), so the apps already under the name stay where they are.
7
8 - **Where.** `/claim` and `/welcome` (`takesName` in `src/web/pages/claim.ts`,
9 `becomeOrg` in `src/spawn/claim.ts`). One transaction, gated by the burned
10 link code; any session the organisation row held is deleted.
11 - **The link code field** is in the server render, visible and required, so the
12 claim works with JavaScript off. The fragment script fills it from `#…` and
13 then hides it.
14 - **Terminal.** The terminal does not take the name: it ends at a link to finish
15 in the browser. Its guest menu's Start my forge gives that link, and a closed
16 sign-up there says to choose it.
17 - **Credit.** A run on the host's included credit is budgeted at most what is
18 left of it (`cappedByLent`), and the host is asked at the run's end
19 (`runEnded`), so the budget and the credit line are one figure.
src/crew/runner.ts +9 −2
@@ -200,7 +200,7 @@ import {
200200 skipsFeedback,
201201 } from "./guard.ts";
202202 import { passportEnv } from "./passport_env.ts";
203 import { fundedEnv } from "../spawn/funding.ts";
203 import { cappedByLent, fundedEnv, lentCreditLeft, runEnded } from "../spawn/funding.ts";
204204
205205 const log = createLog("crew");
206206
@@ -1394,12 +1394,16 @@ async function work(
13941394 }
13951395 // What this run would spend before the account's ceilings: its own
13961396 // budget, under the subscription's reservation or the grant's rest.
1397 const wanted =
1397 const lentLeft = lentCreditLeft(picked.kind === "env" || picked.kind === "none", app.config.dataDir);
1398 const wantedBy =
13981399 picked.kind === "ready" && picked.capUsd !== undefined
13991400 ? Math.min(ownBudget, picked.capUsd)
14001401 : grantCap !== undefined
14011402 ? Math.min(ownBudget, grantCap)
14021403 : ownBudget;
1404 // On the host's included credit a run is budgeted at most what is left of it
1405 // (#11948), so the budget line and the credit line say one number.
1406 const wanted = cappedByLent(wantedBy, lentLeft);
14031407 // The account's own ceilings, daily and per run, apart from the forge's:
14041408 // a run past either does not start and spends nothing. What it may
14051409 // spend is reserved against the day now, so a run started beside it is
@@ -1913,6 +1917,9 @@ async function work(
19131917 await workdir.release();
19141918 deleteToken(app.db, agent.user.id, token.id);
19151919 if (runTmp !== undefined) rmSync(runTmp, { recursive: true, force: true });
1920 // The host counted what the run cost: ask it now, so the credit line moves
1921 // when a run spends (#11948).
1922 void runEnded(app);
19161923 }
19171924 }
19181925
src/settings/plans.ts +1 −1
@@ -38,7 +38,7 @@ export const MAX_USD = 100_000;
3838 export const MAX_FORGES = 1000;
3939
4040 export const DEFAULT_PLANS: readonly Plan[] = [
41 { key: "free", name: "Free", says: "A small forge that sleeps when idle. Bring your own model key.", size: "small", priceUsd: 0, creditUsd: 0, forges: 3 },
41 { key: "free", name: "Free", says: "A small forge that sleeps when idle, with the crew on included credit.", size: "small", priceUsd: 0, creditUsd: 0, forges: 3 },
outdated

The plan's says was one short sentence and is now two clauses. Principle 1: one confident sentence.

4242 { key: "small", name: "Small", says: "A small forge, awake, with a crew credit each month.", size: "small", priceUsd: 19, creditUsd: 5, forges: 3 },
4343 { key: "medium", name: "Medium", says: "A medium forge for a busy app, with more crew credit.", size: "medium", priceUsd: 49, creditUsd: 15, forges: 3 },
4444 { key: "large", name: "Large", says: "A large forge for a working crew, with the most credit.", size: "large", priceUsd: 149, creditUsd: 50, forges: 3 },
src/spawn/claim.ts +40 −1
@@ -345,6 +345,44 @@ export function takeFounder(
345345
346346 class PasskeyRefused extends Error {}
347347
348 /**
349 * Whether a username is the forge's own name, which the forge's organisation
350 * holds: a person who starts "probe" and wants to be "probe" is not refused
351 * (#11948), they become the namespace (`becomeOrg`).
352 */
353 export const isForgeName = (spawned: Spawned, username: string): boolean =>
354 username.toLowerCase() === spawned.org.username.toLowerCase();
355
356 /**
357 * The claimant takes the forge's own name: the organisation row becomes their
358 * account (a person, admin, with the credential given), so the apps already
359 * under that name stay where they are. One transaction; "gone" when the row is
360 * no longer an organisation, "email_taken" when another account has the address.
361 */
362 export function becomeOrg(
363 db: Database,
364 orgId: string,
365 input: { readonly hash: string; readonly email: string; readonly credential?: Credential },
366 ): "ok" | "gone" | "email_taken" {
367 const take = db.transaction((): boolean => {
368 const changed = db
369 .query("UPDATE user SET kind = 'user', password_hash = ?2, email = ?3, is_admin = 1 WHERE id = ?1 AND kind = 'org'")
370 .run(orgId, input.hash, input.email).changes;
371 if (changed !== 1) return false;
372 // Nothing the organisation held as a namespace signs in as the person.
373 db.query("DELETE FROM session WHERE user_id = ?1").run(orgId);
374 if (input.credential !== undefined && !addPasskey(db, orgId, "Passkey", input.credential).ok) throw new PasskeyRefused();
375 return true;
376 });
377 try {
378 return take() ? "ok" : "gone";
379 } catch (error) {
380 if (error instanceof PasskeyRefused) return "gone";
381 if (error instanceof Error && /UNIQUE constraint failed/.test(error.message)) return "email_taken";
382 throw error;
383 }
384 }
385
348386 /**
349387 * Give a spawned child's organisation to its first admin: they join its
350388 * Owners and, unless the spawner asked to keep access, the spawner leaves.
@@ -354,7 +392,8 @@ export function handOver(db: Database, spawned: Spawned, userId: string): void {
354392 const team = listTeams(db, spawned.org.id).find((t) => t.name === OWNERS_TEAM);
355393 if (team === undefined) return;
356394 const spawner = listTeamMembers(db, team.id)[0];
357 addTeamMember(db, team, userId);
395 // The person who took the forge's own name is the organisation row itself: no team to join.
396 if (userId !== spawned.org.id) addTeamMember(db, team, userId);
358397 if (!spawned.keepAccess && spawner && spawner.id !== userId)
359398 removeTeamMember(db, team, spawner.id);
360399 }
src/spawn/funding.ts +24
@@ -90,3 +90,27 @@ export function fundingWords(f: Funding): string {
9090 ? "funded by its parent"
9191 : `funded by its parent: ${usdWords(leftUsd({ capUsd: f.capUsd, spentUsd: f.spentUsd }))} of ${usdWords(f.capUsd)} left`;
9292 }
93
94 /**
95 * What is left of the credit the host lent this forge, when the run draws on
96 * it (`draws`: no account of its own was picked); undefined otherwise.
97 */
98 export function lentCreditLeft(draws: boolean, dataDir: string): number | undefined {
99 if (!draws) return undefined;
100 const f = fundingOf(dataDir);
101 if (f === undefined || f.capUsd === null) return undefined;
102 return Math.max(0, f.capUsd - (f.spentUsd ?? 0));
103 }
104
105 /** A run's budget in USD: what it asked for, never more than the lent credit left (#11948). */
106 export const cappedByLent = (wanted: number, lentLeft: number | undefined): number =>
107 lentLeft === undefined ? wanted : Math.min(wanted, lentLeft);
108
109 /**
110 * A run ended: when this forge is funded, ask the host what it counted, so the
111 * credit line moves. The answer's promise when asked, undefined when not funded.
112 */
113 export function runEnded(app: App, fetcher?: FetchLike): Promise<Funding | undefined> | undefined {
114 if (fundingOf(app.config.dataDir) === undefined) return undefined;
115 return refreshFunding(app, fetcher);
116 }
src/tui/screens/signin.ts +7 −1
@@ -55,6 +55,12 @@ const edgeSignupMessage = (f: Session): string =>
5555 ? "Sign up on the web page only."
5656 : `Sign up on the web page only: ${f.app.config.url}/signup`;
5757
58 /** Where a stranger who cannot sign up here goes: the guest menu's own command. */
59 const GET_FORGE_HINT = "To get a forge of your own, press Esc and choose Start my forge.";
60
61 /** What a closed sign-up says outside an edge: the refusal, then where to go instead. */
62 export const closedSignupText = (): string => `${SIGNUP_CLOSED_MESSAGE} ${GET_FORGE_HINT}`;
63
5864 export function signInScreen(f: Session, ctx: Ctx, onDone: () => void): Screen {
5965 // "Is this forge empty?" is a existence question, not a count: `COUNT(*)`
6066 // walks the whole user table on the one screen every session paints.
@@ -67,7 +73,7 @@ export function signInScreen(f: Session, ctx: Ctx, onDone: () => void): Screen {
6773 // sign a platform user of the same name into. Sign-in is unchanged.
6874 const edge = activeEdgeToken(f.app) !== undefined;
6975 const closed = (): boolean => edge || (!invited() && signupClosed(f.app));
70 const closedText = (): string => (edge ? edgeSignupMessage(f) : SIGNUP_CLOSED_MESSAGE);
76 const closedText = (): string => (edge ? edgeSignupMessage(f) : closedSignupText());
7177 let mode: "signin" | "create" = first && !closed() ? "create" : "signin";
7278 let values = ["", "", "", ""];
7379 let focus = 0;
src/web/pages/claim.ts +21 −2
@@ -43,7 +43,9 @@ import { createSession } from "../../core/sessions.ts";
4343 import { createUser, discardFreshUser, getUserByName, type User } from "../../core/users.ts";
4444 import {
4545 burnClaim,
46 becomeOrg,
4647 founderToTake,
48 isForgeName,
4749 linkOf,
4850 handOver,
4951 isPlaceholderFounder,
@@ -390,7 +392,11 @@ function problems(
390392 errors.username = "Usernames are letters, digits, dots, dashes.";
391393 // The spawn-made founder holds the spawner's own name; the claim may take
392394 // it over (`founderToTake`), so it is not "taken" to the claimant.
393 else if (getUserByName(ctx.app.db, username) && founderToTake(ctx.app, spawned, username) === undefined)
395 else if (
396 getUserByName(ctx.app.db, username) &&
397 founderToTake(ctx.app, spawned, username) === undefined &&
398 !isForgeName(spawned, username)
399 )
394400 errors.username = USERNAME_TAKEN;
395401 if (email !== "" ? !givenEmail(email) : needEmail)
396402 errors.email = "That does not look like an email address.";
@@ -481,8 +487,9 @@ async function submit(ctx: Ctx, own: boolean): Promise<Response> {
481487 // Hashed before the link is spent: nothing is awaited between the burn
482488 // and the takeover.
483489 const founder = founderToTake(ctx.app, spawned, values.username);
490 const takesName = founder === undefined && isForgeName(spawned, values.username);
484491 const founderHash =
485 founder === undefined || credential !== undefined ? "" : await Bun.password.hash(password, "argon2id");
492 (founder === undefined && !takesName) || credential !== undefined ? "" : await Bun.password.hash(password, "argon2id");
486493 // Spent first, in one statement: of two people on one link, one wins.
487494 const spent = burnClaim(ctx.app.db, orgId, token);
488495 if (spent.state !== "ok") return dead(ctx, spent.state, undefined, undefined, own);
@@ -502,6 +509,18 @@ async function submit(ctx: Ctx, own: boolean): Promise<Response> {
502509 }
503510 return claimed(ctx, spawned, founder);
504511 }
512 if (takesName) {
513 // The forge's own name: its organisation row becomes the account, so the
514 // apps under it stay put (#11948).
515 const taken = becomeOrg(ctx.app.db, spawned.org.id, { hash: founderHash, email, ...(credential === undefined ? {} : { credential }) });
516 if (taken !== "ok") {
517 unburnClaim(ctx.app.db, orgId, token);
518 return again(taken === "email_taken" ? { email: EMAIL_TAKEN_MESSAGE } : { username: USERNAME_TAKEN });
519 }
520 const person = getUserByName(ctx.app.db, values.username);
521 if (person === undefined) return again({ username: USERNAME_TAKEN });
522 return claimed(ctx, spawned, person);
523 }
505524 const made = await createUser(ctx.app.db, {
506525 username: values.username,
507526 email,
src/web/pages/new-forge.ts +3 −3
@@ -175,9 +175,9 @@ function freshError(errors: Errors, repoCount: number): string | undefined {
175175 function hostingTerms(includedUsd: number): string {
176176 const crew =
177177 includedUsd > 0
178 ? `its crew starts on the included $${includedUsd} of credit, and you can add your AI account key later`
179 : "its crew starts once you add your AI account";
180 return `Free within limits: it sleeps when idle, and ${crew}.`;
178 ? `The crew builds on the included $${includedUsd} of credit; add your own AI account key to keep going.`
179 : "The crew starts once you add your AI account key.";
outdated

Principle 8, plain words. "model key" is on the jargon list and the other branch now says "AI account key". Use "AI account" in both.

180 return `Free within limits: it sleeps when idle. ${crew}`;
181181 }
182182
183183 function formView(
src/web/style/components.ts +6
@@ -770,6 +770,12 @@ details[open] > summary.row > .row-lead { transform: rotate(90deg); }
770770 font-size: 16px; color: var(--fg);
771771 }
772772 .topbar-search kbd { flex: none; }
773 /* A phone's bar gives the search what is left: the sign-in buttons are never clipped (#11948). */
774 @media (max-width: 479px) {

The 96px flex-basis is a magic number that was never checked on a phone. Say how it was verified at 360px and 390px, and use a token or a named width.

775 .topbar-search { --topbar-search-basis: 6rem; width: auto; flex: 0 1 var(--topbar-search-basis); }
776 .topbar-search kbd { display: none; }
777 .topbar-tools .btn { flex: none; white-space: nowrap; }
778 }
773779
774780 /* ---- theme swatch: the colour chip beside each theme in the menu's picker ---- */
775781 .swatch {
src/web/ui.ts +1 −6
@@ -595,12 +595,7 @@ export function terminalLine(
595595 fingerprint?: string | undefined,
596596 ): Html {
597597 if (terminal.command === undefined)
598 return terminal.reason === "no_listener"
599 ? html`<p class="muted small">
600 The terminal is off — this forge has no SSH listener. Set
601 <span class="mono">FORGE_SSH_PORT</span> to turn it on.
602 </p>`
603 : html`<p class="muted small">The terminal is off.</p>`;
598 return html`<p class="muted small">The terminal is off.</p>`;
604599 return terminalCommand(terminal.command, fingerprint);
outdated

An empty template for one reason and a sentence for the other is dead-branch shape. Pick one rule for both reasons.

605600 }
606601
test/credit-moves.test.ts added +36
@@ -0,0 +1,36 @@
1 /**
2 * The included credit moves when a run spends (#11948): the host is asked at
3 * the run's end, not only at the next heartbeat, and a run on lent credit is
4 * budgeted no more than what is left of it, so the budget line and the credit
5 * line are one figure.
6 */
7 import { afterAll, beforeAll, expect, test } from "bun:test";
8 import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { fundingOf, keepFunding, refreshFunding } from "../src/spawn/funding.ts";
13 import { crewNote } from "../src/web/studio/crew_key.ts";
14
15 let dir = "";
16 let child: App;
17
18 beforeAll(async () => {
19 dir = await mkdtemp(join(tmpdir(), "credit-moves-"));
20 await mkdir(dir, { recursive: true });
21 await writeFile(join(dir, "parent.json"), "{}");
22 child = createApp({ dataDir: dir, port: 0 });
23 keepFunding(dir, { token: "maria.bearer-token", capUsd: 2, spentUsd: 0 });
24 process.env["FORGE_PARENT_LOCAL"] = "http://127.0.0.1:1";
25 });
26 afterAll(async () => {
27 delete process.env["FORGE_PARENT_LOCAL"];
28 await rm(dir, { recursive: true, force: true });
29 });
30
31 test("the credit line moves after the host counts a run", async () => {
32 expect(crewNote(child)).toBe("The crew is building on $2 of $2 included credit.");
33 await refreshFunding(child, async () => new Response(JSON.stringify({ capUsd: 2, spentUsd: 0.5 })));
34 expect(fundingOf(dir)?.spentUsd).toBe(0.5);
35 expect(crewNote(child)).toBe("The crew is building on $1.50 of $2 included credit.");
36 });
test/new-forge-language.test.ts +2 −2
@@ -56,11 +56,11 @@ test("the hosting line says the crew starts on the included credit only when the
5656 const id = app.db.query<{ id: string }, []>("SELECT id FROM user LIMIT 1").get()!.id;
5757 setLimit(app.db, "spawn.included_credit_usd", 3, id);
5858 expect(await say()).toBe(
59 "Free within limits: it sleeps when idle, and its crew starts on the included $3 of credit, and you can add your AI account key later.",
59 "Free within limits: it sleeps when idle. The crew builds on the included $3 of credit; add your own AI account key to keep going.",
6060 );
6161 setLimit(app.db, "spawn.included_credit_usd", 0, id);
6262 expect(await say()).toBe(
63 "Free within limits: it sleeps when idle, and its crew starts once you add your AI account.",
63 "Free within limits: it sleeps when idle. The crew starts once you add your AI account key.",
6464 );
6565 });
6666
test/stranger-first-fixes.test.ts added +141
@@ -0,0 +1,141 @@
1 /**
2 * The pieces of a stranger's first five minutes (#11948) that a page test
3 * does not reach: taking the forge's own name, the credit a run is given, the
4 * terminal's words and the phone's top bar.
5 */
6 import { afterAll, beforeAll, expect, test } from "bun:test";
7 import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import type { Credential, Jwk } from "../src/auth/webauthn.ts";
11 import { createApp, type App } from "../src/app.ts";
12 import { createOrg } from "../src/core/orgs.ts";
13 import { createUser } from "../src/core/users.ts";
14 import { becomeOrg } from "../src/spawn/claim.ts";
15 import { cappedByLent, keepFunding, lentCreditLeft, runEnded } from "../src/spawn/funding.ts";
16 import { closedSignupText } from "../src/tui/screens/signin.ts";
17 import { terminalLine } from "../src/web/ui.ts";
18 import { components as componentsCss } from "../src/web/style/components.ts";
19 import { startedForge, type StartedForge } from "./helpers/claimed-forge.ts";
20 import { quietLogs } from "./helpers/globals.ts";
21
22 quietLogs();
23
24 let dir = "";
25 let app: App;
26 let founderId = "";
27
28 beforeAll(async () => {
29 dir = await mkdtemp(join(tmpdir(), "first-five-"));
30 await mkdir(dir, { recursive: true });
31 await writeFile(join(dir, "parent.json"), "{}");
32 app = createApp({ dataDir: dir, port: 0 });
33 const made = await createUser(app.db, { origin: "seed", trusted: true, username: "founder", email: "[email protected]", password: "a-long-enough-password" });
34 if (!made.ok) throw new Error("user");
35 founderId = made.value.id;
36 });
37 afterAll(async () => {
38 delete process.env["FORGE_PARENT_LOCAL"];
39 await rm(dir, { recursive: true, force: true });
40 });
41
42 const org = (name: string): string => {
43 const made = createOrg(app.db, name, founderId);
44 if (!made.ok) throw new Error(made.error);
45 return made.value.id;
46 };
47 const row = (id: string) => app.db.query<{ kind: string; is_admin: number; email: string }, [string]>("SELECT kind, is_admin, email FROM user WHERE id = ?").get(id)!;
48
49 test("becomeOrg turns the organisation into an admin account and drops any session it held", () => {
50 const id = org("marias");
51 app.db.query("INSERT INTO session (token_hash, user_id, expires_at) VALUES ('h1', ?1, '2999-01-01T00:00:00Z')").run(id);
52 expect(becomeOrg(app.db, id, { hash: "x", email: "[email protected]" })).toBe("ok");
53 expect(row(id)).toEqual({ kind: "user", is_admin: 1, email: "[email protected]" });
54 expect(app.db.query("SELECT 1 FROM session WHERE user_id = ?").get(id)).toBeNull();
55 });
56
57 test("becomeOrg is gone once the row is no longer an organisation", () => {
58 const id = org("bakery");
59 expect(becomeOrg(app.db, id, { hash: "x", email: "[email protected]" })).toBe("ok");
60 expect(becomeOrg(app.db, id, { hash: "y", email: "[email protected]" })).toBe("gone");
61 });
62
63 test("becomeOrg refuses an address another account holds, and changes nothing", () => {
64 const id = org("shop");
65 expect(becomeOrg(app.db, id, { hash: "x", email: "[email protected]" })).toBe("email_taken");
66 expect(row(id).kind).toBe("org");
67 });
68
69 test("becomeOrg keeps the passkey given, and a refused passkey leaves the organisation as it was", () => {
70 const key: Jwk = { kty: "EC", crv: "P-256", x: "AAAA", y: "AAAA" };
71 const credential: Credential = { id: "cred-1", alg: "ES256", key, signCount: 0 };
72 const a = org("vault");
73 expect(becomeOrg(app.db, a, { hash: "", email: "[email protected]", credential })).toBe("ok");
74 expect(app.db.query("SELECT count(*) AS n FROM passkey WHERE user_id = ?").get(a)).toEqual({ n: 1 });
75 const b = org("lockbox");
76 expect(becomeOrg(app.db, b, { hash: "", email: "[email protected]", credential })).toBe("gone");
77 expect(row(b).kind).toBe("org");
78 });
79
80 test("a run on lent credit is budgeted at most what is left of it", () => {
81 keepFunding(dir, { token: "maria.bearer-token", capUsd: 2, spentUsd: 0.5 });
82 expect(lentCreditLeft(true, dir)).toBe(1.5);
83 expect(lentCreditLeft(false, dir)).toBeUndefined();
84 expect(cappedByLent(5, lentCreditLeft(true, dir))).toBe(1.5);
85 expect(cappedByLent(1, lentCreditLeft(true, dir))).toBe(1);
86 expect(cappedByLent(5, lentCreditLeft(false, dir))).toBe(5);
87 });
88
89 test("a run's end asks the host what it counted, only when this forge is funded", async () => {
90 process.env["FORGE_PARENT_LOCAL"] = "http://127.0.0.1:1";
91 const asked: string[] = [];
92 const fetcher = async (url: string) => {
93 asked.push(url);
94 return new Response(JSON.stringify({ capUsd: 2, spentUsd: 1 }));
95 };
96 const asking = runEnded(app, fetcher);
97 expect(asking).toBeDefined();
98 await asking;
99 expect(asked).toEqual(["http://127.0.0.1:1/api/v1/credit"]);
100 const bare = createApp({ dataDir: await mkdtemp(join(tmpdir(), "first-five-bare-")), port: 0 });
101 expect(runEnded(bare, fetcher)).toBeUndefined();
102 await rm(bare.config.dataDir, { recursive: true, force: true });
103 expect(asked.length).toBe(1);
104 });
105
106 test("the terminal line has one rule: a command, or that the terminal is off", () => {
107 expect(String(terminalLine({ reason: "no_listener" }))).toContain("The terminal is off.");
108 expect(String(terminalLine({ reason: "no_tty" }))).toContain("The terminal is off.");
109 expect(String(terminalLine({ command: "ssh -p 2222 x@localhost" }))).toContain("ssh -p 2222 x@localhost");
110 });
111
112 test("a closed sign-up in the terminal points at Start my forge", () => {
113 expect(closedSignupText()).toContain("choose Start my forge");
114 });
115
116 test("the phone's top bar names its search width and keeps the buttons whole", () => {
117 const css = String(componentsCss);
118 expect(css).toContain("--topbar-search-basis");
119 expect(css).not.toContain("flex: 0 1 96px");
120 expect(css).toMatch(/\.topbar-tools \.btn \{[^}]*flex: none/);
121 });
122
123 let fresh: StartedForge;
124 test("the sent link's /claim takes the forge's own name too, with the code field visible", async () => {
125 fresh = await startedForge("forge-claim-name-");
126 try {
127 const html = await (await fetch(`${fresh.base}/claim`)).text();
128 expect(html).not.toMatch(/<div id="claim-token-field"[^>]*hidden/);
129 const name = fresh.child.db.query<{ username: string }, []>("SELECT username FROM user WHERE kind = 'org' LIMIT 1").get()!.username;
130 const res = await fetch(`${fresh.base}/claim`, {
131 method: "POST",
132 headers: { origin: fresh.base },
133 body: new URLSearchParams({ token: fresh.token, username: name, email: "[email protected]", password: "a-long-enough-password" }),
134 redirect: "manual",
135 });
136 expect(res.status).toBe(303);
137 expect(fresh.child.db.query("SELECT kind FROM user WHERE username = ?").get(name)).toEqual({ kind: "user" });
138 } finally {
139 await fresh.stop();
140 }
141 }, 60_000);
test/studio-door.test.ts +3 −3
@@ -971,14 +971,14 @@ describe("the studio app page's terminal", () => {
971971 // asserts the line names FORGE_SSH_PORT instead, composed from the same
972972 // sshPort field the other tests here already pin (#77's ground) rather
973973 // than by shutting a real listener down.
974 test("a hosted forge with no SSH listener says why, and names FORGE_SSH_PORT", async () => {
974 test("a hosted forge with no SSH listener says the terminal is off, with nothing an operator would act on (#11948)", async () => {
975975 putLive("bun run src/tty.ts");
976976 app.config.sshPort = 0;
977977 try {
978978 const mine = await appPage(writer);
979 expect(mine).toContain("FORGE_SSH_PORT");
979 expect(mine).not.toContain("FORGE_SSH_PORT");
980 expect(mine).toContain("The terminal is off.");
980981 expect(mine).not.toContain("ssh -p 2222 [email protected]");
981 expect(mine).not.toContain("The terminal is off.</p>");
982982 } finally {
983983 app.config.sshPort = 2222;
984984 }
test/welcome.test.ts +25
@@ -94,3 +94,28 @@ test("the home's scripts join without calling the one before", () => {
9494 expect(() => new Function(joined)).not.toThrow();
9595 for (const part of [funnelJs, startNameJs]) expect(part.trimEnd().endsWith(";")).toBe(true);
9696 });
97
98 test("the link code field is visible and required in the server render, and the forge's own name is a name a person may take (#11948)", async () => {
99 const started = await startedForge("forge-welcome-name-");
100 try {
101 const html = await (await fetch(`${started.base}/welcome`)).text();
102 expect(html).not.toMatch(/<div id="claim-token-field"[^>]*hidden/);
103 expect(html).toMatch(/<input[^>]*name="token"[^>]*required|<input[^>]*required[^>]*name="token"/);
104 const org = started.child.db.query<{ username: string }, []>("SELECT username FROM user WHERE kind = 'org' LIMIT 1").get()!.username;
105 const res = await fetch(`${started.base}/welcome`, {
106 method: "POST",
107 headers: { origin: started.base },
108 body: new URLSearchParams({ token: started.token, username: org, password: "a-long-enough-password" }),
109 redirect: "manual",
110 });
111 expect(res.status).toBe(303);
112 expect(res.headers.get("set-cookie")).toContain("HttpOnly");
113 const row = started.child.db.query<{ kind: string; is_admin: number }, [string]>("SELECT kind, is_admin FROM user WHERE username = ?").get(org)!;
114 expect(row.kind).toBe("user");
115 expect(row.is_admin).toBe(1);
116 const home = await fetch(`${started.base}/`, { headers: { cookie: (res.headers.get("set-cookie") ?? "").split(";")[0]! } });
117 expect(home.status).toBe(200);
118 } finally {
119 await started.stop();
120 }
121 }, 60_000);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs