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

Every preview of the forge takes looks of a new forge's first screen by itself: light and dark, phone and desktop #11299

Merged opened this

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

Merged

scout/forge:scout/11285-every-preview-of-the-forge-takes-looks-o → main 3 commits fork 4 threads · 4 unresolved

  • Merged. scout/11285-every-preview-of-the-forge-takes-looks-o was merged into main as 77b14f5.

Review

Sign in to review this change.
8 files changed +202 −12
src/core/next-steps.ts +4 −1
@@ -13,7 +13,7 @@ import type { App } from "../app.ts";
1313 import { spawnedOrg } from "../spawn/claim.ts";
outdated

Core should not import a preview module for a user-setting key; move PREVIEW_SEEDED to a core settings home.

1414 import { adminOf, everydayOf, keptAdmin } from "./everyday.ts";
1515 import { listReposByOwner, type Repo } from "./repos.ts";
16 import { getUserSetting, setUserSetting } from "./settings.ts";
16 import { PREVIEW_SEEDED, getUserSetting, setUserSetting } from "./settings.ts";
1717 import { getUserById, type User } from "./users.ts";
1818
1919 export type StepKey = "everyday" | "apps" | "tell";
@@ -45,6 +45,9 @@ export async function nextStepsFor(
4545 if (!user) return undefined;
4646 const owner = ownerFor(app, user);
4747 if (!owner) return undefined;
48 // The preview admin is a site admin of a seeded world, not a newcomer: its
49 // home stays the full one though the preview has a parent.json (#11285).
50 if (getUserSetting(app.db, owner.id, PREVIEW_SEEDED) !== undefined) return undefined;
4851 if ((await spawnedOrg(app)) === undefined) return undefined;
4952 const daily = everydayOf(app.db, owner);
5053 const people = [owner.id, ...(daily ? [daily.id] : [])];
src/core/settings.ts +3
@@ -10,6 +10,9 @@ import type { Database } from "bun:sqlite";
1010
1111 const MAX_VALUE = 200;
1212
13 /** The setting that says the preview admin was seeded: only a preview has it. */
14 export const PREVIEW_SEEDED = "preview_seeded";
15
1316 export function getUserSetting(
1417 db: Database,
1518 userId: string,
src/looks/service.ts +5 −2
@@ -62,7 +62,7 @@ import { getPull } from "../core/pulls.ts";
6262 import { frontPicks } from "../core/front.ts";
6363 import { getRepoById } from "../core/repos.ts";
6464 import { getUserById } from "../core/users.ts";
65 import { ownerLooksFor } from "../spawn/preview-looks.ts";
65 import { ownerLooksFor, PREVIEW_NEWCOMER } from "../spawn/preview-looks.ts";
6666 import { thumbnail } from "../design/image/image.ts";
6767 import { decodePng, encodePng, pngHeader } from "../design/image/png.ts";
6868 import { createLog } from "../kernel/log.ts";
@@ -408,6 +408,9 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle {
408408 ? undefined
409409 : { id: owner.id, name: owner.username, role: "admin" };
410410 const host = runtime.hostFor(record.slug, item.env);
411 const newcomer: GrantWho = { id: PREVIEW_NEWCOMER, name: PREVIEW_NEWCOMER, role: "admin" };
412 const isNewcomerLook = (page: string): boolean =>
413 ownerLooks.some((l) => l.page === page && l.as === "newcomer");
411414 let allOk = true;
412415 for (const { page, kind, theme } of wanted) {
413416 if (stopped) return;
@@ -416,7 +419,7 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle {
416419 { app: record, env: item.env },
417420 GRANT_TTL_MS,
418421 Date.now(),
419 asNobody(kind, page) ? undefined : asOwner,
422 asNobody(kind, page) ? undefined : isNewcomerLook(page) ? newcomer : asOwner,
420423 );
outdated

Nested ternary with a literal id; extract a named helper and use the constant for id.

421424 const url = `http://${host}:${port}/__forge/grant?t=${encodeURIComponent(token)}&to=${encodeURIComponent(page)}`;
422425 const path = lookPath(item.appId, item.env, item.commitOid, page, kind, theme);
src/spawn/preview-looks.ts +18 −1
@@ -148,9 +148,21 @@ export const PREVIEW_MACHINES_PAGE = `${PREVIEW_REPO_HOME}/settings/machines`;
148148 export const PREVIEW_DISK_ALERT = "nvme1";
149149 export const PREVIEW_DISK_OPEN_LOOK = `${PREVIEW_MACHINES_PAGE}#disk-standard-${PREVIEW_DISK_ALERT}-figures`;
150150
151 /**
152 * The newcomer (#11285): a preview-only person of a spawned forge who has
153 * claimed it and told the crew nothing, so their home is the first-login
154 * card at "What do you want built?". Their look is of `/` with a query the
155 * page ignores, so it is a page of its own beside the admin's home.
156 */
157 export const PREVIEW_NEWCOMER = "preview-newcomer";
158 export const PREVIEW_NEWCOMER_ORG = "preview-newcomer-forge";
159 export const PREVIEW_NEWCOMER_PAGE = "/?as=newcomer";
160
151161 export interface OwnerLook {
152162 readonly page: string;
153163 readonly kind: LookKind;
164 /** Taken as the newcomer, not the preview admin. */
165 readonly as?: "newcomer";
154166 }
155167
156168 /** The named list: each page at desk and phone, taken as the owner. */
@@ -177,10 +189,15 @@ export const PREVIEW_OWNER_LOOKS: readonly OwnerLook[] = [
177189 PREVIEW_ISSUE_EDIT_PAGE,
178190 PREVIEW_RESILIENCE_PAGE,
179191 PREVIEW_SEARCH_PAGE,
192 PREVIEW_NEWCOMER_PAGE,
180193 PREVIEW_MACHINES_PAGE,
181194 PREVIEW_DISK_OPEN_LOOK,
182195 ...PREVIEW_SETTINGS_PAGES,
183 ].flatMap((page) => (["desktop", "phone"] as const).map((kind) => ({ page, kind })));
196 ].flatMap((page) =>
197 (["desktop", "phone"] as const).map((kind): OwnerLook =>
198 page === PREVIEW_NEWCOMER_PAGE ? { page, kind, as: "newcomer" } : { page, kind },
199 ),
200 );
184201
185202 /**
186203 * The owner looks a picture of `repo`'s `env` must include: the named list
src/spawn/preview-owner.ts +5 −3
@@ -15,7 +15,8 @@
1515 import { createHmac, timingSafeEqual } from "node:crypto";
1616 import type { App } from "../app.ts";
1717 import type { User } from "../core/users.ts";
18 import { isPreviewEnv, previewAdmin } from "./preview-seed.ts";
18 import { PREVIEW_NEWCOMER } from "./preview-looks.ts";
19 import { isPreviewEnv, previewAdmin, previewNewcomer } from "./preview-seed.ts";
1920
2021 type Env = Record<string, string | undefined>;
2122
@@ -45,8 +46,9 @@ export function previewOwner(
4546 return undefined;
4647 const parts = identity.split("|");
4748 if (parts.length !== 4) return undefined;
48 const [, , role, expires] = parts as [string, string, string, string];
49 const [, name, role, expires] = parts as [string, string, string, string];
4950 if (role !== "admin" || !/^\d+$/.test(expires) || Number(expires) <= now)
5051 return undefined;
51 return previewAdmin(app);
52 // The parent's looks of the first screen are taken as the newcomer (#11285).
53 return name === PREVIEW_NEWCOMER ? previewNewcomer(app) : previewAdmin(app);
5254 }
src/spawn/preview-seed.ts +77 −4
@@ -14,7 +14,10 @@
1414 * to show or log. Once: the admin remembers that it was seeded.
1515 */
1616
17 import { existsSync } from "node:fs";
18 import { join } from "node:path";
1719 import type { App } from "../app.ts";
20 import { createOrg } from "../core/orgs.ts";
1821 import type { Ctx } from "../http/context.ts";
1922 import type { Issue } from "../core/issues.ts";
2023 import { getRepo, type Repo } from "../core/repos.ts";
@@ -45,7 +48,7 @@ import { getAgentByName } from "../core/agents.ts";
4548 import { createSession, destroySession } from "../core/sessions.ts";
4649 import { createToken, listTokens } from "../core/tokens.ts";
4750 import { recordUsage } from "../core/usage.ts";
48 import { getUserSetting, setUserSetting } from "../core/settings.ts";
51 import { PREVIEW_SEEDED, getUserSetting, setUserSetting } from "../core/settings.ts";
4952 import { createUser, getUserByName, type User } from "../core/users.ts";
5053 import { diskSummary, recordReading } from "../machines/disk_readings.ts";
5154 import { err, ok, type Result } from "../kernel/result.ts";
@@ -66,6 +69,8 @@ import {
6669 PREVIEW_MOVED_OWNER,
6770 PREVIEW_MOVED_PAGE,
6871 PREVIEW_MOVE_PAGE,
72 PREVIEW_NEWCOMER,
73 PREVIEW_NEWCOMER_ORG,
6974 PREVIEW_REPO,
7075 PREVIEW_SETTINGS_PAGES,
7176 } from "./preview-looks.ts";
@@ -206,7 +211,7 @@ export const PREVIEW_LANDING_TITLE = `The changelog wraps a long landing title o
206211 const PREVIEW_BOARD = "preview";
207212 const PREVIEW_AGENT = "preview-agent";
208213 const PREVIEW_TOKEN = "preview fake token, not a credential";
209 const SEEDED = "preview_seeded";
214 const SEEDED = PREVIEW_SEEDED;
210215 const CONFIG_SEEDED = "preview_config_seeded";
211216 const SETTINGS_SEEDED = "preview_settings_seeded";
212217
@@ -223,6 +228,70 @@ export function previewAdmin(app: App): User | undefined {
223228 return user?.isAdmin === true && user.kind !== "agent" ? user : undefined;
224229 }
225230
231 /** The newcomer, when this forge has one. */
232 export function previewNewcomer(app: App): User | undefined {
233 const user = getUserByName(app.db, PREVIEW_NEWCOMER);
234 return user?.isAdmin === true && user.kind === "user" ? user : undefined;
235 }
236
237 /**
238 * The person of a spawned forge who has claimed it and told the crew nothing
239 * (#11285): the preview is given a `parent.json` naming a seeded organisation,
240 * so it reads as spawned, and an admin who has made no tell. Their home is
241 * then the first-login card at "What do you want built?". Idempotent; only a
242 * preview reaches it (`seedPreview` refuses anything else). The refusal says
243 * what the page lacked.
244 */
245 async function seedNewcomer(app: App): Promise<Result<void, string>> {
246 let created = false;
247 let person = getUserByName(app.db, PREVIEW_NEWCOMER);
248 if (person === undefined) {
249 const made = await createUser(app.db, {
250 origin: "seed",
251 trusted: true,
252 isAdmin: true,
253 allowReserved: true,
254 username: PREVIEW_NEWCOMER,
255 email: `${PREVIEW_NEWCOMER}@preview.invalid`,
256 });
257 if (!made.ok) return err(`the preview newcomer: ${made.error}`);
258 person = made.value;
259 created = true;
260 }
261 if (!person.isAdmin || person.kind !== "user") return err("the preview newcomer's name is someone else's");
262 if (getUserByName(app.db, PREVIEW_NEWCOMER_ORG) === undefined) {
263 const org = createOrg(app.db, PREVIEW_NEWCOMER_ORG, person.id, { allowReserved: true });
264 if (!org.ok) return err(`the preview newcomer's organisation: ${org.error}`);
265 created = true;
266 }
267 const file = join(app.config.dataDir, "parent.json");
268 if (!existsSync(file)) {
269 await Bun.write(file, JSON.stringify({ org: PREVIEW_NEWCOMER_ORG }));
270 created = true;
271 }
272 // The self-check builds a whole server: only when the newcomer was just made.
273 if (!created) return ok(undefined);
274 const http = forgeHttp(app);
275 const session = createSession(app.db, person.id);
276 try {
277 const res = await http.handle(new Request("http://localhost/", { headers: { cookie: `forge_session=${session.token}` } }));
278 return checkFirstTell(res.status, await res.text());
279 } finally {
280 destroySession(app.db, session.token);
281 http.close();
282 }
283 }
284
285 /** Whether a newcomer's home answered 200 with the one box and no passkey form above it. */
286 export function checkFirstTell(status: number, body: string): Result<void, string> {
287 if (status !== 200) return err(`the newcomer's home answered ${String(status)}`);
288 const box = body.indexOf("What do you want built?");
289 if (box < 0) return err("the newcomer's home lacks \"What do you want built?\"");
290 const passkey = body.search(/passkey/i);
291 if (passkey >= 0 && passkey < box) return err("the newcomer's home has a passkey form above the box");
292 return ok(undefined);
293 }
294
226295 /**
227296 * Seed the preview admin and its world, once. Refused (`not_preview`)
228297 * anywhere the platform has not said `preview`; `seeded` is false when an
@@ -256,7 +325,9 @@ export async function seedPreview(
256325 const disks = await seedDisks(app, admin);
257326 if (!disks.ok) return disks;
258327 const files = await seedConfigFiles(app, admin);
259 return files.ok ? ok({ seeded: false }) : files;
328 if (!files.ok) return files;
329 const newcomer = await seedNewcomer(app);
330 return newcomer.ok ? ok({ seeded: false }) : newcomer;
260331 }
261332 const world = await seedWorld(app, admin);
262333 if (!world.ok) return world;
@@ -268,7 +339,9 @@ export async function seedPreview(
268339 const disks = await seedDisks(app, admin);
269340 if (!disks.ok) return disks;
270341 const files = await seedConfigFiles(app, admin);
271 return files.ok ? ok({ seeded: true }) : files;
342 if (!files.ok) return files;
343 const newcomer = await seedNewcomer(app);
344 return newcomer.ok ? ok({ seeded: true }) : newcomer;
272345 }
273346
274347 /**
test/preview-newcomer.test.ts added +89
@@ -0,0 +1,89 @@
1 /** The preview's newcomer: a claimed, untold person whose home is the first-login card (#11285). */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { existsSync } from "node:fs";
4 import { mkdtemp, rename, rm } from "node:fs/promises";
5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
7 import { createApp, type App } from "../src/app.ts";
8 import { serve, type Servers } from "../src/http/server.ts";
9 import { createSession } from "../src/core/sessions.ts";
10 import { PREVIEW_NEWCOMER, PREVIEW_NEWCOMER_PAGE, PREVIEW_OWNER_LOOKS, ownerLooksFor } from "../src/spawn/preview-looks.ts";
11 import { checkFirstTell, previewAdmin, previewNewcomer, seedPreview } from "../src/spawn/preview-seed.ts";
12
13 let dir: string;
14 let app: App;
15 let servers: Servers;

Global env mutation leaks across files; pass env explicitly or restore it.

16 beforeAll(async () => {
17 dir = await mkdtemp(join(tmpdir(), "forge-preview-newcomer-"));
18 app = createApp({ dataDir: dir, port: 0 });
19 const seeded = await seedPreview(app, { FORGE_APP_ENV: "preview" });
20 if (!seeded.ok) throw new Error(seeded.error);
21 servers = await serve(app);
22 }, 30_000);
23 afterAll(async () => {
24 servers.stop();
25 app.close();
26 await rm(dir, { recursive: true, force: true });
27 });
28
29 test("the newcomer's home answers 200 with the one box and no passkey form above it", async () => {
30 const cookie = `forge_session=${createSession(app.db, previewNewcomer(app)!.id).token}`;
31 const res = await fetch(`${servers.url}/`, { headers: { cookie } });
32 const body = await res.text();
33 expect(res.status).toBe(200);
34 expect(body).toContain("What do you want built?");
35 expect(body).toContain("data-first-tell");
36 expect(checkFirstTell(res.status, body).ok).toBe(true);
37 });
38
39 test("the preview admin's home is still the full one", async () => {
40 const cookie = `forge_session=${createSession(app.db, previewAdmin(app)!.id).token}`;
41 const body = await (await fetch(`${servers.url}/`, { headers: { cookie } })).text();
42 expect(body).not.toContain("data-first-tell");
43 });
44
45 test("parent.json leaves the admin's settings page and /apps as they were", async () => {
46 const cookie = `forge_session=${createSession(app.db, previewAdmin(app)!.id).token}`;
47 const read = async (path: string) => {
48 const res = await fetch(`${servers.url}${path}`, { headers: { cookie } });
49 expect(res.status).toBe(200);
50 return (await res.text()).replace(/<(script|style)[\s\S]*?<\/\1>/g, "").replace(/\d{1,2}:\d{2}|\bago\b|\d+ ?[smhd]\b/g, "");
51 };
52 const file = join(dir, "parent.json");
53 expect(existsSync(file)).toBe(true);
54 const withParent = [await read("/settings"), await read("/apps")];
55 await rename(file, `${file}.off`);
56 try {
57 expect([await read("/settings"), await read("/apps")]).toEqual(withParent);
58 } finally {
59 await rename(`${file}.off`, file);
60 }
61 });
62
63 test("the check refuses a home without the box, a non-200, or a passkey form above it", () => {
64 expect(checkFirstTell(404, "What do you want built?").ok).toBe(false);
65 expect(checkFirstTell(200, "<h2>Home</h2>").ok).toBe(false);
66 expect(checkFirstTell(200, "<form>Make a passkey</form><h2>What do you want built?</h2>").ok).toBe(false);
67 });
68
69 test("it is listed as a look taken as the newcomer, at desktop and phone, for the forge's own preview only", () => {
70 for (const kind of ["desktop", "phone"] as const)
71 expect(PREVIEW_OWNER_LOOKS).toContainEqual({ page: PREVIEW_NEWCOMER_PAGE, kind, as: "newcomer" });
72 const repo = { ownerName: "trevato", name: "forge" };
73 expect(ownerLooksFor("trevato/forge", repo, "preview").some((l) => l.as === "newcomer")).toBe(true);
74 expect(ownerLooksFor("trevato/forge", repo, "production")).toEqual([]);
75 });
76
77 test("nothing seeds outside a preview", async () => {
78 const other = await mkdtemp(join(tmpdir(), "forge-preview-newcomer-prod-"));
79 const prod = createApp({ dataDir: other, port: 0 });
80 try {
81 expect((await seedPreview(prod, { FORGE_APP_ENV: "production" })).ok).toBe(false);
82 expect(previewNewcomer(prod)).toBeUndefined();
83 expect(existsSync(join(other, "parent.json"))).toBe(false);
84 expect(PREVIEW_NEWCOMER).toBe("preview-newcomer");
85 } finally {
86 prod.close();
87 await rm(other, { recursive: true, force: true });
88 }
89 });
test/signup-gate-parent.test.ts +1 −1
@@ -118,7 +118,7 @@ test("every caller of createUser says where its account comes from", async () =>
118118 "src/chaos/world.ts": ["seed"],
119119 "src/packs/mirror.ts": ["seed"],
120120 "src/packs/up.ts": ["seed"],
121 "src/spawn/preview-seed.ts": ["seed", "seed"],
121 "src/spawn/preview-seed.ts": ["seed", "seed", "seed"],
122122 "src/spawn/starter_crew.ts": ["spawn"],
123123 "src/web/pages/claim.ts": ["claim"],
124124 "src/web/pages/everyday.ts": ["everyday"],

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs