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

A forge preview takes owner looks of Settings › Tokens, Crew, Subscriptions and Usage #9790

Merged opened this

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

Merged

scout/forge:scout/9781-a-forge-preview-takes-owner-looks-of-set → main 2 commits fork 2 threads · 2 unresolved

  • Merged. scout/9781-a-forge-preview-takes-owner-looks-of-set was merged into main as 2644b75.

Review

Sign in to review this change.
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
9494 { theme: "digest", titles: ["Digest groups mentions", "Digest honours quiet hours", "Digest links threads"] },
9595 ];
9696
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
97109 export interface OwnerLook {
98110 readonly page: string;
99111 readonly kind: LookKind;
@@ -118,6 +130,7 @@ export const PREVIEW_OWNER_LOOKS: readonly OwnerLook[] = [
118130 PREVIEW_ISSUE_PAGE,
119131 PREVIEW_RESILIENCE_PAGE,
120132 PREVIEW_SEARCH_PAGE,
133 ...PREVIEW_SETTINGS_PAGES,
121134 ].flatMap((page) => (["desktop", "phone"] as const).map((kind) => ({ page, kind })));
122135
123136 /**
src/spawn/preview-seed.ts +58 −1
@@ -34,7 +34,11 @@ import { addDependency } from "../core/dependencies.ts";
3434 import { claimRun, createRun, finishRun } from "../core/runs.ts";
3535 import { setMember } from "../core/roles.ts";
3636 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";
3842 import { recordUsage } from "../core/usage.ts";
3943 import { getUserSetting, setUserSetting } from "../core/settings.ts";
4044 import { createUser, getUserByName, type User } from "../core/users.ts";
@@ -49,6 +53,7 @@ import {
4953 PREVIEW_PULL,
5054 PREVIEW_PULL_PAGE,
5155 PREVIEW_REPO,
56 PREVIEW_SETTINGS_PAGES,
5257 } from "./preview-looks.ts";
5358
5459 export {
@@ -75,8 +80,11 @@ export const PREVIEW_WAVE_HEADLINE = "The sample says hello";
7580 export const PREVIEW_LANDING_TITLE = `The changelog wraps a long landing title on a phone ${"L".repeat(120)} and the words after it`;
7681 /** The board whose panels are pinned to home. */
7782 const PREVIEW_BOARD = "preview";
83 const PREVIEW_AGENT = "preview-agent";
84 const PREVIEW_TOKEN = "preview fake token, not a credential";
7885 const SEEDED = "preview_seeded";
7986 const CONFIG_SEEDED = "preview_config_seeded";
87 const SETTINGS_SEEDED = "preview_settings_seeded";
8088
8189 type Env = Record<string, string | undefined>;
8290
@@ -117,16 +125,65 @@ export async function seedPreview(
117125 if (!admin.isAdmin || admin.kind === "agent")
118126 return err("the preview admin's name is someone else's");
119127 if (getUserSetting(app.db, admin.id, SEEDED) !== undefined) {
128 const settings = await seedSettingsPages(app, admin);
129 if (!settings.ok) return settings;
120130 const files = await seedConfigFiles(app, admin);
121131 return files.ok ? ok({ seeded: false }) : files;
122132 }
123133 const world = await seedWorld(app, admin);
124134 if (!world.ok) return world;
125135 setUserSetting(app.db, admin.id, SEEDED, new Date().toISOString());
136 const settings = await seedSettingsPages(app, admin);
137 if (!settings.ok) return settings;
126138 const files = await seedConfigFiles(app, admin);
127139 return files.ok ? ok({ seeded: true }) : files;
128140 }
129141
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);
outdated

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.

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
130187 /**
131188 * The config files the form is tried on, once: a sample seeded before they
132189 * 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";
99 import { join } from "node:path";
1010 import { createApp, type App } from "../src/app.ts";
1111 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";
1417 import { THEMES_AT_REST } from "../src/web/pages/gate.ts";
1518 import { reviewGate } from "../src/actions/gate_review.ts";
1619 import { serve, type Servers } from "../src/http/server.ts";
@@ -88,3 +91,76 @@ describe("the seeded first issue", () => {
8891 expect(html).toContain("A seeded run");
8992 });
9093 });
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs