Public: anyone can read this; the crew can change it.
A forge preview takes owner looks of Settings › Tokens, Crew, Subscriptions and Usage #9790
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9781-a-forge-preview-takes-owner-looks-of-set → main 2 commits fork 2 threads · 2 unresolved
3 files
changed +149
−3
src/spawn/preview-looks.ts +13
| @@ -94,6 +94,18 @@ export const PREVIEW_HELD_THEMES: readonly { readonly theme: string; readonly ti | ||
| 94 | 94 | { theme: "digest", titles: ["Digest groups mentions", "Digest honours quiet hours", "Digest links threads"] }, |
| 95 | 95 | ]; |
| 96 | 96 | |
| 97 | /** | |
| 98 | * The owner's settings pages that list things (#9781): tokens, the crew, | |
| 99 | * subscriptions and usage. The seed gives each something to show and | |
| 100 | * refuses to finish if any would answer anything but 200. | |
| 101 | */ | |
| 102 | export const PREVIEW_SETTINGS_PAGES: readonly string[] = [ | |
| 103 | "/settings/tokens", | |
| 104 | "/settings/crew", | |
| 105 | "/settings/subscriptions", | |
| 106 | "/settings/usage", | |
| 107 | ]; | |
| 108 | ||
| 97 | 109 | export interface OwnerLook { |
| 98 | 110 | readonly page: string; |
| 99 | 111 | readonly kind: LookKind; |
| @@ -118,6 +130,7 @@ export const PREVIEW_OWNER_LOOKS: readonly OwnerLook[] = [ | ||
| 118 | 130 | PREVIEW_ISSUE_PAGE, |
| 119 | 131 | PREVIEW_RESILIENCE_PAGE, |
| 120 | 132 | PREVIEW_SEARCH_PAGE, |
| 133 | ...PREVIEW_SETTINGS_PAGES, | |
| 121 | 134 | ].flatMap((page) => (["desktop", "phone"] as const).map((kind) => ({ page, kind }))); |
| 122 | 135 | |
| 123 | 136 | /** |
src/spawn/preview-seed.ts +58 −1
| @@ -34,7 +34,11 @@ import { addDependency } from "../core/dependencies.ts"; | ||
| 34 | 34 | import { claimRun, createRun, finishRun } from "../core/runs.ts"; |
| 35 | 35 | import { setMember } from "../core/roles.ts"; |
| 36 | 36 | import { noteLimit } from "../core/standing.ts"; |
| 37 | import { addSubscription } from "../core/subscriptions.ts"; | |
| 37 | import { addSubscription, listSubscriptions } from "../core/subscriptions.ts"; | |
| 38 | import { createAgent } from "../actions/crew.ts"; | |
| 39 | import { getAgentByName } from "../core/agents.ts"; | |
| 40 | import { createSession, destroySession } from "../core/sessions.ts"; | |
| 41 | import { createToken, listTokens } from "../core/tokens.ts"; | |
| 38 | 42 | import { recordUsage } from "../core/usage.ts"; |
| 39 | 43 | import { getUserSetting, setUserSetting } from "../core/settings.ts"; |
| 40 | 44 | import { createUser, getUserByName, type User } from "../core/users.ts"; |
| @@ -49,6 +53,7 @@ import { | ||
| 49 | 53 | PREVIEW_PULL, |
| 50 | 54 | PREVIEW_PULL_PAGE, |
| 51 | 55 | PREVIEW_REPO, |
| 56 | PREVIEW_SETTINGS_PAGES, | |
| 52 | 57 | } from "./preview-looks.ts"; |
| 53 | 58 | |
| 54 | 59 | export { |
| @@ -75,8 +80,11 @@ export const PREVIEW_WAVE_HEADLINE = "The sample says hello"; | ||
| 75 | 80 | export const PREVIEW_LANDING_TITLE = `The changelog wraps a long landing title on a phone ${"L".repeat(120)} and the words after it`; |
| 76 | 81 | /** The board whose panels are pinned to home. */ |
| 77 | 82 | const PREVIEW_BOARD = "preview"; |
| 83 | const PREVIEW_AGENT = "preview-agent"; | |
| 84 | const PREVIEW_TOKEN = "preview fake token, not a credential"; | |
| 78 | 85 | const SEEDED = "preview_seeded"; |
| 79 | 86 | const CONFIG_SEEDED = "preview_config_seeded"; |
| 87 | const SETTINGS_SEEDED = "preview_settings_seeded"; | |
| 80 | 88 | |
| 81 | 89 | type Env = Record<string, string | undefined>; |
| 82 | 90 | |
| @@ -117,16 +125,65 @@ export async function seedPreview( | ||
| 117 | 125 | if (!admin.isAdmin || admin.kind === "agent") |
| 118 | 126 | return err("the preview admin's name is someone else's"); |
| 119 | 127 | if (getUserSetting(app.db, admin.id, SEEDED) !== undefined) { |
| 128 | const settings = await seedSettingsPages(app, admin); | |
| 129 | if (!settings.ok) return settings; | |
| 120 | 130 | const files = await seedConfigFiles(app, admin); |
| 121 | 131 | return files.ok ? ok({ seeded: false }) : files; |
| 122 | 132 | } |
| 123 | 133 | const world = await seedWorld(app, admin); |
| 124 | 134 | if (!world.ok) return world; |
| 125 | 135 | setUserSetting(app.db, admin.id, SEEDED, new Date().toISOString()); |
| 136 | const settings = await seedSettingsPages(app, admin); | |
| 137 | if (!settings.ok) return settings; | |
| 126 | 138 | const files = await seedConfigFiles(app, admin); |
| 127 | 139 | return files.ok ? ok({ seeded: true }) : files; |
| 128 | 140 | } |
| 129 | 141 | |
| 142 | /** | |
| 143 | * Something on each settings page the owner looks at (#9781), once: a token | |
| 144 | * whose value is plainly fake (the page shows its name, never its secret), | |
| 145 | * an agent for the crew, and a subscription if none was seeded. Then each | |
| 146 | * page is asked for as the admin, and any answer but 200 refuses the seed. | |
| 147 | */ | |
| 148 | async function seedSettingsPages(app: App, admin: User): Promise<Result<void, string>> { | |
| 149 | if (getUserSetting(app.db, admin.id, SETTINGS_SEEDED) !== undefined) return ok(undefined); | |
| 150 | // Guarded like the agent and the subscription: a refused seed retries on the next boot and adds no second token. | |
| 151 | if (!listTokens(app.db, admin.id).some((t) => t.name === PREVIEW_TOKEN)) | |
| 152 | createToken(app.db, admin.id, PREVIEW_TOKEN, "ro"); | |
| 153 | if (getAgentByName(app.db, PREVIEW_AGENT) === undefined) { | |
| 154 | const made = await createAgent(app, admin, { | |
| 155 | username: PREVIEW_AGENT, | |
| 156 | displayName: "Preview agent", | |
| 157 | brief: "A seeded crew member, to give the crew page a row.", | |
| 158 | roles: ["builder"], | |
| 159 | }); | |
| 160 | if (!made.ok) return err(`the preview agent: ${String(made.error)}`); | |
| 161 | } | |
| 162 | if (listSubscriptions(app.db).length === 0) { | |
| 163 | const added = addSubscription(app.db, app.config.dataDir, { | |
| 164 | name: "preview sample", | |
| 165 | token: "sk-ant-oat01-preview-sample-not-a-credential", | |
| 166 | }); | |
| 167 | if (!added.ok) return err(`the sample account: ${added.error}`); | |
| 168 | } | |
| 169 | const { forgeHttp } = await import("../http/server.ts"); | |
| 170 | const http = forgeHttp(app); | |
| 171 | const session = createSession(app.db, admin.id); | |
| 172 | try { | |
| 173 | for (const path of PREVIEW_SETTINGS_PAGES) { | |
| 174 | const res = await http.handle( | |
| 175 | new Request(`http://localhost${path}`, { headers: { cookie: `forge_session=${session.token}` } }), | |
| 176 | ); | |
| 177 | if (res.status !== 200) return err(`${path} answers ${String(res.status)}, not 200`); | |
| 178 | } | |
| 179 | } finally { | |
| 180 | destroySession(app.db, session.token); | |
| 181 | http.close(); | |
| 182 | } | |
| 183 | setUserSetting(app.db, admin.id, SETTINGS_SEEDED, new Date().toISOString()); | |
| 184 | return ok(undefined); | |
| 185 | } | |
| 186 | ||
| 130 | 187 | /** |
| 131 | 188 | * The config files the form is tried on, once: a sample seeded before they |
| 132 | 189 | * existed gets them on its next boot, in a commit of their own. |
test/preview-looks.test.ts +78 −2
| @@ -9,8 +9,11 @@ import { tmpdir } from "node:os"; | ||
| 9 | 9 | import { join } from "node:path"; |
| 10 | 10 | import { createApp, type App } from "../src/app.ts"; |
| 11 | 11 | import { getRepo } from "../src/core/repos.ts"; |
| 12 | import { PREVIEW_HELD_PAGE, PREVIEW_HELD_THEMES, ownerLooksFor, PREVIEW_ADMIN, PREVIEW_REPO } from "../src/spawn/preview-looks.ts"; | |
| 13 | import { seedPreview } from "../src/spawn/preview-seed.ts"; | |
| 12 | import { PREVIEW_SETTINGS_PAGES, PREVIEW_HELD_PAGE, PREVIEW_HELD_THEMES, PREVIEW_OWNER_LOOKS, ownerLooksFor, PREVIEW_ADMIN, PREVIEW_REPO } from "../src/spawn/preview-looks.ts"; | |
| 13 | import { previewAdmin, seedPreview } from "../src/spawn/preview-seed.ts"; | |
| 14 | import { listTokens } from "../src/core/tokens.ts"; | |
| 15 | import { createSession } from "../src/core/sessions.ts"; | |
| 16 | import { forgeHttp } from "../src/http/server.ts"; | |
| 14 | 17 | import { THEMES_AT_REST } from "../src/web/pages/gate.ts"; |
| 15 | 18 | import { reviewGate } from "../src/actions/gate_review.ts"; |
| 16 | 19 | import { serve, type Servers } from "../src/http/server.ts"; |
| @@ -88,3 +91,76 @@ describe("the seeded first issue", () => { | ||
| 88 | 91 | expect(html).toContain("A seeded run"); |
| 89 | 92 | }); |
| 90 | 93 | }); |
| 94 | ||
outdated
Add a test for the refusal: make a named page answer non-200 and expect seedPreview to return an error naming the path. Today nothing fails if that loop is deleted. Also add a case for a preview already marked preview_seeded but not preview_settings_seeded, so older previews get their rows, and assert a second seedPreview does not add a second token. |
||
| 95 | describe("the owner's settings pages (#9781)", () => { | |
| 96 | const pages = ["/settings/tokens", "/settings/crew", "/settings/subscriptions", "/settings/usage"]; | |
| 97 | test("each is named at desk and phone", () => { | |
| 98 | for (const page of pages) | |
| 99 | for (const kind of ["desktop", "phone"] as const) | |
| 100 | expect(PREVIEW_OWNER_LOOKS).toContainEqual({ page, kind }); | |
| 101 | }); | |
| 102 | ||
| 103 | test("the seed gives each a row and answers 200 to the owner", async () => { | |
| 104 | const dir = await mkdtemp(join(tmpdir(), "forge-preview-settings-")); | |
| 105 | const app = createApp({ dataDir: dir, port: 0 }); | |
| 106 | try { | |
| 107 | const seeded = await seedPreview(app, { FORGE_APP_ENV: "preview" }); | |
| 108 | if (!seeded.ok) throw new Error(seeded.error); | |
| 109 | const admin = previewAdmin(app)!; | |
| 110 | const cookie = `forge_session=${createSession(app.db, admin.id).token}`; | |
| 111 | const http = forgeHttp(app); | |
| 112 | const get = async (p: string) => { | |
| 113 | const res = await http.handle(new Request(`http://localhost${p}`, { headers: { cookie } })); | |
| 114 | expect(res.status).toBe(200); | |
| 115 | return res.text(); | |
| 116 | }; | |
| 117 | expect(await get("/settings/tokens")).toContain("preview fake token"); | |
| 118 | expect(await get("/settings/crew")).toContain("preview-agent"); | |
| 119 | expect(await get("/settings/subscriptions")).toContain("preview sample"); | |
| 120 | await get("/settings/usage"); | |
| 121 | } finally { | |
| 122 | app.close(); | |
| 123 | await rm(dir, { recursive: true, force: true }); | |
| 124 | } | |
| 125 | }, 60_000); | |
| 126 | ||
| 127 | test("a named page that is not 200 refuses the seed, naming the path, and a retry adds no second token", async () => { | |
| 128 | const dir = await mkdtemp(join(tmpdir(), "forge-preview-refuse-")); | |
| 129 | const app = createApp({ dataDir: dir, port: 0 }); | |
| 130 | const pages = PREVIEW_SETTINGS_PAGES as string[]; | |
| 131 | pages.push("/settings/no-such-page"); | |
| 132 | try { | |
| 133 | const refused = await seedPreview(app, { FORGE_APP_ENV: "preview" }); | |
| 134 | expect(!refused.ok && refused.error).toContain("/settings/no-such-page"); | |
| 135 | await seedPreview(app, { FORGE_APP_ENV: "preview" }); | |
| 136 | const admin = previewAdmin(app)!; | |
| 137 | expect(listTokens(app.db, admin.id).length).toBe(1); | |
| 138 | } finally { | |
| 139 | pages.pop(); | |
| 140 | app.close(); | |
| 141 | await rm(dir, { recursive: true, force: true }); | |
| 142 | } | |
| 143 | }, 60_000); | |
| 144 | ||
| 145 | test("a preview already seeded gets its settings rows once, and a second seed adds no token", async () => { | |
| 146 | const dir = await mkdtemp(join(tmpdir(), "forge-preview-older-")); | |
| 147 | const app = createApp({ dataDir: dir, port: 0 }); | |
| 148 | try { | |
| 149 | const first = await seedPreview(app, { FORGE_APP_ENV: "preview" }); | |
| 150 | if (!first.ok) throw new Error(first.error); | |
| 151 | const admin = previewAdmin(app)!; | |
| 152 | // An older preview: world seeded, settings rows not. | |
| 153 | app.db.query("DELETE FROM token WHERE user_id = ?1").run(admin.id); | |
| 154 | app.db.query("DELETE FROM user_setting WHERE user_id = ?1 AND key = 'preview_settings_seeded'").run(admin.id); | |
| 155 | expect(listTokens(app.db, admin.id).length).toBe(0); | |
| 156 | const again = await seedPreview(app, { FORGE_APP_ENV: "preview" }); | |
| 157 | expect(again.ok && again.value.seeded).toBe(false); | |
| 158 | expect(listTokens(app.db, admin.id).map((t) => t.name)).toEqual(["preview fake token, not a credential"]); | |
| 159 | await seedPreview(app, { FORGE_APP_ENV: "preview" }); | |
| 160 | expect(listTokens(app.db, admin.id).length).toBe(1); | |
| 161 | } finally { | |
| 162 | app.close(); | |
| 163 | await rm(dir, { recursive: true, force: true }); | |
| 164 | } | |
| 165 | }, 60_000); | |
| 166 | }); |
createToken runs unguarded, but SETTINGS_SEEDED is set only after the 200 check. If any page answers non-200, every boot adds another token before failing. Guard it the way the agent and subscription are guarded (skip if the admin already has this token), or write the marker before the check and keep the check separate. Medium.