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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11285-every-preview-of-the-forge-takes-looks-o → main 3 commits fork 4 threads · 4 unresolved
8 files
changed +202
−12
src/core/next-steps.ts +4 −1
| @@ -13,7 +13,7 @@ import type { App } from "../app.ts"; | ||
| 13 | 13 | import { spawnedOrg } from "../spawn/claim.ts"; |
| 14 | 14 | import { adminOf, everydayOf, keptAdmin } from "./everyday.ts"; |
| 15 | 15 | import { listReposByOwner, type Repo } from "./repos.ts"; |
| 16 | import { getUserSetting, setUserSetting } from "./settings.ts"; | |
| 16 | import { PREVIEW_SEEDED, getUserSetting, setUserSetting } from "./settings.ts"; | |
| 17 | 17 | import { getUserById, type User } from "./users.ts"; |
| 18 | 18 | |
| 19 | 19 | export type StepKey = "everyday" | "apps" | "tell"; |
| @@ -45,6 +45,9 @@ export async function nextStepsFor( | ||
| 45 | 45 | if (!user) return undefined; |
| 46 | 46 | const owner = ownerFor(app, user); |
| 47 | 47 | 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; | |
| 48 | 51 | if ((await spawnedOrg(app)) === undefined) return undefined; |
| 49 | 52 | const daily = everydayOf(app.db, owner); |
| 50 | 53 | const people = [owner.id, ...(daily ? [daily.id] : [])]; |
src/core/settings.ts +3
| @@ -10,6 +10,9 @@ import type { Database } from "bun:sqlite"; | ||
| 10 | 10 | |
| 11 | 11 | const MAX_VALUE = 200; |
| 12 | 12 | |
| 13 | /** The setting that says the preview admin was seeded: only a preview has it. */ | |
| 14 | export const PREVIEW_SEEDED = "preview_seeded"; | |
| 15 | ||
| 13 | 16 | export function getUserSetting( |
| 14 | 17 | db: Database, |
| 15 | 18 | userId: string, |
src/looks/service.ts +5 −2
| @@ -62,7 +62,7 @@ import { getPull } from "../core/pulls.ts"; | ||
| 62 | 62 | import { frontPicks } from "../core/front.ts"; |
| 63 | 63 | import { getRepoById } from "../core/repos.ts"; |
| 64 | 64 | import { getUserById } from "../core/users.ts"; |
| 65 | import { ownerLooksFor } from "../spawn/preview-looks.ts"; | |
| 65 | import { ownerLooksFor, PREVIEW_NEWCOMER } from "../spawn/preview-looks.ts"; | |
| 66 | 66 | import { thumbnail } from "../design/image/image.ts"; |
| 67 | 67 | import { decodePng, encodePng, pngHeader } from "../design/image/png.ts"; |
| 68 | 68 | import { createLog } from "../kernel/log.ts"; |
| @@ -408,6 +408,9 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle { | ||
| 408 | 408 | ? undefined |
| 409 | 409 | : { id: owner.id, name: owner.username, role: "admin" }; |
| 410 | 410 | 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"); | |
| 411 | 414 | let allOk = true; |
| 412 | 415 | for (const { page, kind, theme } of wanted) { |
| 413 | 416 | if (stopped) return; |
| @@ -416,7 +419,7 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle { | ||
| 416 | 419 | { app: record, env: item.env }, |
| 417 | 420 | GRANT_TTL_MS, |
| 418 | 421 | Date.now(), |
| 419 | asNobody(kind, page) ? undefined : asOwner, | |
| 422 | asNobody(kind, page) ? undefined : isNewcomerLook(page) ? newcomer : asOwner, | |
| 420 | 423 | ); |
outdated
Nested ternary with a literal id; extract a named helper and use the constant for id. |
||
| 421 | 424 | const url = `http://${host}:${port}/__forge/grant?t=${encodeURIComponent(token)}&to=${encodeURIComponent(page)}`; |
| 422 | 425 | 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`; | ||
| 148 | 148 | export const PREVIEW_DISK_ALERT = "nvme1"; |
| 149 | 149 | export const PREVIEW_DISK_OPEN_LOOK = `${PREVIEW_MACHINES_PAGE}#disk-standard-${PREVIEW_DISK_ALERT}-figures`; |
| 150 | 150 | |
| 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 | ||
| 151 | 161 | export interface OwnerLook { |
| 152 | 162 | readonly page: string; |
| 153 | 163 | readonly kind: LookKind; |
| 164 | /** Taken as the newcomer, not the preview admin. */ | |
| 165 | readonly as?: "newcomer"; | |
| 154 | 166 | } |
| 155 | 167 | |
| 156 | 168 | /** The named list: each page at desk and phone, taken as the owner. */ |
| @@ -177,10 +189,15 @@ export const PREVIEW_OWNER_LOOKS: readonly OwnerLook[] = [ | ||
| 177 | 189 | PREVIEW_ISSUE_EDIT_PAGE, |
| 178 | 190 | PREVIEW_RESILIENCE_PAGE, |
| 179 | 191 | PREVIEW_SEARCH_PAGE, |
| 192 | PREVIEW_NEWCOMER_PAGE, | |
| 180 | 193 | PREVIEW_MACHINES_PAGE, |
| 181 | 194 | PREVIEW_DISK_OPEN_LOOK, |
| 182 | 195 | ...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 | ); | |
| 184 | 201 | |
| 185 | 202 | /** |
| 186 | 203 | * 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 @@ | ||
| 15 | 15 | import { createHmac, timingSafeEqual } from "node:crypto"; |
| 16 | 16 | import type { App } from "../app.ts"; |
| 17 | 17 | 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"; | |
| 19 | 20 | |
| 20 | 21 | type Env = Record<string, string | undefined>; |
| 21 | 22 | |
| @@ -45,8 +46,9 @@ export function previewOwner( | ||
| 45 | 46 | return undefined; |
| 46 | 47 | const parts = identity.split("|"); |
| 47 | 48 | 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]; | |
| 49 | 50 | if (role !== "admin" || !/^\d+$/.test(expires) || Number(expires) <= now) |
| 50 | 51 | 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); | |
| 52 | 54 | } |
src/spawn/preview-seed.ts +77 −4
| @@ -14,7 +14,10 @@ | ||
| 14 | 14 | * to show or log. Once: the admin remembers that it was seeded. |
| 15 | 15 | */ |
| 16 | 16 | |
| 17 | import { existsSync } from "node:fs"; | |
| 18 | import { join } from "node:path"; | |
| 17 | 19 | import type { App } from "../app.ts"; |
| 20 | import { createOrg } from "../core/orgs.ts"; | |
| 18 | 21 | import type { Ctx } from "../http/context.ts"; |
| 19 | 22 | import type { Issue } from "../core/issues.ts"; |
| 20 | 23 | import { getRepo, type Repo } from "../core/repos.ts"; |
| @@ -45,7 +48,7 @@ import { getAgentByName } from "../core/agents.ts"; | ||
| 45 | 48 | import { createSession, destroySession } from "../core/sessions.ts"; |
| 46 | 49 | import { createToken, listTokens } from "../core/tokens.ts"; |
| 47 | 50 | import { recordUsage } from "../core/usage.ts"; |
| 48 | import { getUserSetting, setUserSetting } from "../core/settings.ts"; | |
| 51 | import { PREVIEW_SEEDED, getUserSetting, setUserSetting } from "../core/settings.ts"; | |
| 49 | 52 | import { createUser, getUserByName, type User } from "../core/users.ts"; |
| 50 | 53 | import { diskSummary, recordReading } from "../machines/disk_readings.ts"; |
| 51 | 54 | import { err, ok, type Result } from "../kernel/result.ts"; |
| @@ -66,6 +69,8 @@ import { | ||
| 66 | 69 | PREVIEW_MOVED_OWNER, |
| 67 | 70 | PREVIEW_MOVED_PAGE, |
| 68 | 71 | PREVIEW_MOVE_PAGE, |
| 72 | PREVIEW_NEWCOMER, | |
| 73 | PREVIEW_NEWCOMER_ORG, | |
| 69 | 74 | PREVIEW_REPO, |
| 70 | 75 | PREVIEW_SETTINGS_PAGES, |
| 71 | 76 | } from "./preview-looks.ts"; |
| @@ -206,7 +211,7 @@ export const PREVIEW_LANDING_TITLE = `The changelog wraps a long landing title o | ||
| 206 | 211 | const PREVIEW_BOARD = "preview"; |
| 207 | 212 | const PREVIEW_AGENT = "preview-agent"; |
| 208 | 213 | const PREVIEW_TOKEN = "preview fake token, not a credential"; |
| 209 | const SEEDED = "preview_seeded"; | |
| 214 | const SEEDED = PREVIEW_SEEDED; | |
| 210 | 215 | const CONFIG_SEEDED = "preview_config_seeded"; |
| 211 | 216 | const SETTINGS_SEEDED = "preview_settings_seeded"; |
| 212 | 217 | |
| @@ -223,6 +228,70 @@ export function previewAdmin(app: App): User | undefined { | ||
| 223 | 228 | return user?.isAdmin === true && user.kind !== "agent" ? user : undefined; |
| 224 | 229 | } |
| 225 | 230 | |
| 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 | ||
| 226 | 295 | /** |
| 227 | 296 | * Seed the preview admin and its world, once. Refused (`not_preview`) |
| 228 | 297 | * anywhere the platform has not said `preview`; `seeded` is false when an |
| @@ -256,7 +325,9 @@ export async function seedPreview( | ||
| 256 | 325 | const disks = await seedDisks(app, admin); |
| 257 | 326 | if (!disks.ok) return disks; |
| 258 | 327 | 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; | |
| 260 | 331 | } |
| 261 | 332 | const world = await seedWorld(app, admin); |
| 262 | 333 | if (!world.ok) return world; |
| @@ -268,7 +339,9 @@ export async function seedPreview( | ||
| 268 | 339 | const disks = await seedDisks(app, admin); |
| 269 | 340 | if (!disks.ok) return disks; |
| 270 | 341 | 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; | |
| 272 | 345 | } |
| 273 | 346 | |
| 274 | 347 | /** |
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 () => | ||
| 118 | 118 | "src/chaos/world.ts": ["seed"], |
| 119 | 119 | "src/packs/mirror.ts": ["seed"], |
| 120 | 120 | "src/packs/up.ts": ["seed"], |
| 121 | "src/spawn/preview-seed.ts": ["seed", "seed"], | |
| 121 | "src/spawn/preview-seed.ts": ["seed", "seed", "seed"], | |
| 122 | 122 | "src/spawn/starter_crew.ts": ["spawn"], |
| 123 | 123 | "src/web/pages/claim.ts": ["claim"], |
| 124 | 124 | "src/web/pages/everyday.ts": ["everyday"], |
Core should not import a preview module for a user-setting key; move PREVIEW_SEEDED to a core settings home.