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

A preview of the forge can be looked at as its owner: looks of chosen pages are taken signed in, over seeded data #8830

Merged opened this

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

Merged

mason/forge:mason/8812-a-preview-of-the-forge-can-be-looked-at → main 5 commits fork 1 thread · 1 unresolved

  • Merged. mason/8812-a-preview-of-the-forge-can-be-looked-at was merged into main as 902b031.

Review

Sign in to review this change.
13 files changed +631 −13
docs/APPS.md +5 −1
@@ -1260,7 +1260,11 @@ a signed-in admin exactly as when the owner opens it — and they are its
12601260 writers' alone. The thumb is taken as nobody, for the tiles strangers see. A
12611261 public app on a public repository is the world's to see, so all three of its
12621262 pictures are taken as nobody, and a reader of its pulls sees the Look card
1263 too; each row records who it was `taken_as` (#727, #738). It is taken as the page loads — what an
1263 too; each row records who it was `taken_as` (#727, #738). A page named in
1264 `app.looksAsOwner` (each must also be in `app.looks`) is the exception: its
1265 phone and desktop are taken as the owner even of a public app, and stay the
1266 writers' — an admin's own page shows a stranger nothing (#8812,
1267 `docs/features/preview-owner-looks.md`). It is taken as the page loads — what an
12641268 app draws after that is not in it — and one at a time, forge-wide, bounded
12651269 in time and memory. A look is best-effort: it never blocks a check or fails
12661270 a run; one that could not be taken is recorded with its reason, and the
docs/features/preview-owner-looks.md added +31
@@ -0,0 +1,31 @@
1 # A preview of the forge, looked at as its owner
2
3 Looks of a public app on a public repository are taken signed out, and most
4 of the forge's own pages are an admin's: a preview's pictures of home, /flow
5 and /talk came out blank. Two halves fix that (#8812).
6
7 **The parent picks the pages.** `app.looksAsOwner` in `forge.json` names
8 pages of `app.looks` whose phone and desktop pictures are taken signed in
9 as the repository's owner, an admin, through the grant door
10 (`src/looks/service.ts`). Every other page is still taken as nobody, and
11 every thumb too. Pictures taken as the owner are the writers' alone
12 (`mayFetchLook`), so a reader of the pull does not see them. A page named
13 there that `app.looks` does not name is a manifest error. The forge names
14 `/`, `/flow` and `/talk`.
15
16 **The preview has an owner to be.** When the platform says
17 `FORGE_APP_ENV=preview`, the forge seeds a site admin, `preview-admin`, and
18 a small world for it once (`src/spawn/preview-seed.ts`): a public repository
19 `preview-admin/sample` with two issues and an open pull, three agent runs
20 that spent this week and a valve turned to 60% (the flow week), and two
21 board panels pinned to home. In a preview, a request whose
22 `x-forge-identity` is signed under `FORGE_APP_IDENTITY_KEY` and names an
23 admin is that admin (`src/spawn/preview-owner.ts`) — the parent's looks, and
24 any writer who opens the preview from the forge. It counts as an edge
25 identity, so mutations are held to the same-origin rule.
26
27 **Production refuses both.** Anywhere `FORGE_APP_ENV` is not `preview`, the
28 seed answers `not_preview` and makes no one, and the key is never read, so
29 the identity headers are ignored. The admin has no password and no session:
30 nothing to show, nothing to log. `test/looks-owner.test.ts` proves each
31 of these.
forge.json +1
@@ -4,6 +4,7 @@
44 ],
55 "app": {
66 "looks": ["/", "/forges", "/family", "/front", "/explore", "/board", "/boards", "/notes", "/feed", "/review", "/search", "/new", "/audit", "/releases", "/changelog", "/apps", "/repos", "/map", "/system", "/talk", "/contribute", "/flow", "/resilience", "/settings"],
7 "looksAsOwner": ["/", "/flow", "/talk"],
78 "run": "bun run src/main.ts",
89 "install": "",
910 "health": "/healthz",
src/app.ts +7
@@ -64,6 +64,13 @@ export interface ForgeConfig {
6464 * in by the platform. Unset, those headers are a claim and are ignored.
6565 */
6666 edgeToken?: string;
67 /**
68 * In a preview of this forge run as another's app, the key the parent
69 * signs its visitors' identity with (`FORGE_APP_IDENTITY_KEY`): set, a
70 * visitor it names as an admin is the preview admin
71 * (`spawn/preview-owner.ts`). Never set in production.
72 */
73 previewIdentityKey?: string;
6774 /**
6875 * Let anyone, not only an admin, migrate from a host inside the network
6976 * (loopback, private ranges): a forge run for one person on one machine.
src/apps/manifest.ts +48 −8
@@ -39,6 +39,13 @@ export interface AppManifest {
3939 * before this field existed.
4040 */
4141 readonly looks: readonly string[];
42 /**
43 * `app.looksAsOwner`: the pages of `looks` whose phone and desktop
44 * pictures are taken signed in as the owner, even of a public app —
45 * the pages a stranger is shown nothing on (`looks/service.ts`). Unset
46 * or empty is none, exactly as before this field existed.
47 */
48 readonly looksAsOwner?: readonly string[] | undefined;
4249 /**
4350 * Set when `app.kind` is `"service"`: declared TCP ports, always on,
4451 * a placement preference and synced volumes. `null` for an ordinary
@@ -142,6 +149,8 @@ export function parseManifest(
142149 );
143150 const looks = looksField(app);
144151 if (!looks.ok) return looks;
152 const looksAsOwner = looksAsOwnerField(app, looks.value);
153 if (!looksAsOwner.ok) return looksAsOwner;
145154 const egress = egressField(parsed.value);
146155 if (!egress.ok) return egress;
147156 const limits = limitsField(app, "app");
@@ -174,6 +183,7 @@ export function parseManifest(
174183 ready: ready ?? READY_DEFAULT,
175184 tty: tty.value || null,
176185 looks: looks.value,
186 looksAsOwner: looksAsOwner.value,
177187 service,
178188 egress: egress.value,
179189 limits: limits.value,
@@ -268,28 +278,58 @@ function limitsField(
268278 function looksField(app: Json): Result<readonly string[], ManifestError> {
269279 const value = app["looks"];
270280 if (value === undefined) return ok(["/"]);
281 const paths = pathList(value, "app.looks");
282 if (!paths.ok) return paths;
283 return ok(paths.value.length === 0 ? ["/"] : paths.value);
284 }
285
286 /**
287 * `app.looksAsOwner`: pages of `app.looks` to picture signed in as the
288 * owner. Held to the same rules, and each must be a page `app.looks`
289 * names — a page nobody pictures cannot be pictured as anyone.
290 */
291 function looksAsOwnerField(
292 app: Json,
293 looks: readonly string[],
294 ): Result<readonly string[], ManifestError> {
295 const value = app["looksAsOwner"];
296 if (value === undefined) return ok([]);
297 const paths = pathList(value, "app.looksAsOwner");
298 if (!paths.ok) return paths;
299 const stray = paths.value.find((p) => !looks.includes(p));
300 if (stray !== undefined)
301 return invalid(
302 `forge.json: app.looksAsOwner names ${stray}, which app.looks does not`,
303 );
304 return ok(paths.value);
305 }
306
307 /** A list of page paths, as `app.looks` and `app.looksAsOwner` take them. */
308 function pathList(
309 value: unknown,
310 field: string,
311 ): Result<string[], ManifestError> {
271312 if (!Array.isArray(value))
272 return invalid("forge.json: app.looks must be an array of paths");
313 return invalid(`forge.json: ${field} must be an array of paths`);
273314 if (value.length > LOOKS_MAX)
274 return invalid(`forge.json: app.looks may name at most ${LOOKS_MAX} paths`);
275 if (value.length === 0) return ok(["/"]);
315 return invalid(`forge.json: ${field} may name at most ${LOOKS_MAX} paths`);
276316 const paths: string[] = [];
277317 for (const item of value) {
278318 if (typeof item !== "string")
279 return invalid("forge.json: app.looks entries must be strings");
319 return invalid(`forge.json: ${field} entries must be strings`);
280320 if (item.length > LOOK_PATH_MAX)
281321 return invalid(
282 `forge.json: app.looks entries must be at most ${LOOK_PATH_MAX} characters`,
322 `forge.json: ${field} entries must be at most ${LOOK_PATH_MAX} characters`,
283323 );
284324 if (!item.startsWith("/") || item.startsWith("//"))
285325 return invalid(
286 "forge.json: app.looks entries must be a path starting with / (no scheme, no host)",
326 `forge.json: ${field} entries must be a path starting with / (no scheme, no host)`,
287327 );
288328 if (item.includes(".."))
289 return invalid("forge.json: app.looks entries must not contain ..");
329 return invalid(`forge.json: ${field} entries must not contain ..`);
290330 if (/[\x00-\x1f\x7f]/.test(item))
291331 return invalid(
292 "forge.json: app.looks entries must not contain control characters",
332 `forge.json: ${field} entries must not contain control characters`,
293333 );
294334 paths.push(item);
295335 }
src/apps/runtime.ts +4
@@ -213,6 +213,8 @@ export interface EnvStatus {
213213 * as `tty`.
214214 */
215215 readonly looks?: readonly string[] | undefined;
216 /** The pages of `looks` pictured as the owner (`app.looksAsOwner`), once known live. */
217 readonly looksAsOwner?: readonly string[] | undefined;
216218 /** `false` when its `forge.json` turns the forge pill off; unset is on. */
217219 readonly toolbar?: boolean | undefined;
218220 /**
@@ -537,6 +539,7 @@ export class AppsRuntime {
537539 createdAt: state.live.createdAt,
538540 tty: state.live.manifest.tty,
539541 looks: state.live.manifest.looks,
542 looksAsOwner: state.live.manifest.looksAsOwner,
540543 toolbar: state.live.manifest.toolbar,
541544 ...(state.live.deployStartedAt === null
542545 ? {}
@@ -574,6 +577,7 @@ export class AppsRuntime {
574577 createdAt: state.live.createdAt,
575578 tty: state.live.manifest.tty,
576579 looks: state.live.manifest.looks,
580 looksAsOwner: state.live.manifest.looksAsOwner,
577581 toolbar: state.live.manifest.toolbar,
578582 ...(state.live.deployStartedAt === null
579583 ? {}
src/http/server.ts +7
@@ -502,6 +502,7 @@ import { stopCellChildren } from "../spawn/cell.ts";
502502 import { portInsteadOfSocket, tcpAddress } from "../git/hook-address.ts";
503503 import { answerFromCell, closeHostedChildren, resolveHostedChild, resolveHostedChildApp, socketToCell } from "../spawn/host.ts";
504504 import { getVerifiedDomain } from "../core/domains.ts";
505 import { previewOwner } from "../spawn/preview-owner.ts";
505506
506507 // ---- auth ----
507508
@@ -517,6 +518,12 @@ async function resolveAuth(app: App, request: Request): Promise<Auth> {
517518 if (user) return { user, scope: "admin", via: "edge" };
518519 }
519520
521 // In a preview of this forge, the parent forge in front of it vouches for
522 // its admins as the preview admin (`spawn/preview-owner.ts`): an edge of
523 // its own, ridden by its grant cookie, so held to the same-origin rule.
524 const owner = previewOwner(app, request);
525 if (owner) return { user: owner, scope: "admin", via: "edge" };
526
520527 const session = cookieValue(request, "forge_session");
521528 if (session !== undefined) {
522529 const found = sessionOf(app.db, session);
src/looks/service.ts +11 −4
@@ -372,8 +372,15 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle {
372372 // A private app turns a signed-out visitor away to sign in, on another
373373 // host: its thumb is taken through the grant door as the owner, like
374374 // its other pictures, and never leaves the app's host (#7830).
375 const asNobody = (kind: LookKind): boolean =>
376 !sealed && (everyone || (kind === "thumb" && record.access === "public"));
375 // A page the manifest names in `app.looksAsOwner` shows a stranger
376 // nothing worth a picture — an admin's own page — so its phone and
377 // desktop are the owner's, and stay the writers', even of a public app
378 // (#8812). Its thumb is still what a stranger sees.
379 const ownerPages = new Set(status.looksAsOwner ?? []);
380 const asNobody = (kind: LookKind, page: string): boolean =>
381 !sealed &&
382 !(kind !== "thumb" && ownerPages.has(page)) &&
383 (everyone || (kind === "thumb" && record.access === "public"));
377384 const owner = getUserById(app.db, repo.ownerId);
378385 const asOwner: GrantWho | undefined =
379386 owner === undefined
@@ -390,7 +397,7 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle {
390397 { app: record, env: item.env },
391398 GRANT_TTL_MS,
392399 Date.now(),
393 asNobody(kind) ? undefined : asOwner,
400 asNobody(kind, page) ? undefined : asOwner,
394401 );
395402 const url = `http://${host}:${port}/__forge/grant?t=${encodeURIComponent(token)}&to=${encodeURIComponent(page)}`;
396403 const path = lookPath(item.appId, item.env, item.commitOid, page, kind, theme);
@@ -445,7 +452,7 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle {
445452 ...row,
446453 kind,
447454 path,
448 takenAs: asNobody(kind) ? "nobody" : "owner",
455 takenAs: asNobody(kind, page) ? "nobody" : "owner",
449456 });
450457 } else {
451458 rmSync(staged, { force: true });
src/main.ts +15
@@ -21,6 +21,8 @@ import { offerStartingBoards } from "./actions/boards.ts";
2121 import { syncAllPillars } from "./actions/pillars.ts";
2222 import { bootHostedChildren } from "./spawn/host.ts";
2323 import { firstBoot } from "./spawn/first-boot.ts";
24 import { previewIdentityKey } from "./spawn/preview-owner.ts";
25 import { PREVIEW_ADMIN, seedPreview } from "./spawn/preview-seed.ts";
2426 import { startHeartbeat } from "./spawn/heartbeat.ts";
2527 import { startUpstreamWatch } from "./spawn/upstream.ts";
2628 import { startReachWatch } from "./spawn/reach.ts";
@@ -217,6 +219,8 @@ watchStalls(loopLog, undefined, undefined, undefined, (late, at) => {
217219 if (stall !== null) loopLog.warn(stallLine(stall));
218220 });
219221
222 const previewKey = previewIdentityKey(process.env);
223
220224 const app = createApp({
221225 dataDir,
222226 // A new forge starts with signup = invite (FORGE_SIGNUP below still wins).
@@ -228,6 +232,9 @@ const app = createApp({
228232 : { host: process.env.FORGE_HOST }),
229233 ...(canonicalUrl === "" ? {} : { url: new URL(canonicalUrl).origin }),
230234 ...(edgeToken === "" ? {} : { edgeToken }),
235 // A preview of this forge hears its parent's admins as the preview admin
236 // (spawn/preview-owner.ts); production is never handed the key.
237 ...(previewKey === undefined ? {} : { previewIdentityKey: previewKey }),
231238 // Hosted, the host says how this forge's terminal is reached; printed
232239 // verbatim wherever the forge tells people how to connect.
233240 ...(hosted && (process.env.FORGE_APP_SSH ?? "").trim() !== ""
@@ -505,6 +512,14 @@ void bootHostedChildren(app);
505512 // A spawned child's first boot: its direction, opened once for its planner
506513 // (spawn/first-boot.ts). A forge that was not spawned is untouched.
507514 await firstBoot(app);
515 // A preview of this forge seeds its admin and a small world to look at
516 // (spawn/preview-seed.ts); anywhere else this is refused and says nothing.
517 await seedPreview(app)
518 .then((seeded) => {
519 if (seeded.ok && seeded.value.seeded) app.log.info("preview seeded", { admin: PREVIEW_ADMIN });
520 else if (!seeded.ok && seeded.error !== "not_preview") app.log.warn("preview not seeded", { error: seeded.error });
521 })
522 .catch((error) => app.log.warn("preview not seeded", { error: String(error) }));
508523 // A spawned child reports its standing to its parent (spawn/heartbeat.ts).
509524 const heartbeatTimer = startHeartbeat(app);
510525 // ...and looks upstream once an hour (spawn/upstream.ts).
src/spawn/preview-owner.ts added +52
@@ -0,0 +1,52 @@
1 /**
2 * Who the parent forge vouches for, in a preview of this one (#8812). A
3 * forge run as an app of another hears the visitor in `x-forge-identity`,
4 * signed under `FORGE_APP_IDENTITY_KEY` (`apps/proxy.ts`); the parent drops
5 * any copy a client sent, so a verified signature is the parent's word.
6 *
7 * In a preview, and only there, a visitor the parent names as an admin of
8 * the repository — a writer, or the parent's looks taken as the owner — is
9 * this forge's preview admin (`spawn/preview-seed.ts`). A writer can push a
10 * branch that runs anything over a preview's data, so this grants nothing
11 * they lacked. Anyone else, and every request in production, is left to
12 * the forge's own sign-in: production is never handed the key.
13 */
14
15 import { createHmac, timingSafeEqual } from "node:crypto";
16 import type { App } from "../app.ts";
17 import type { User } from "../core/users.ts";
18 import { isPreviewEnv, previewAdmin } from "./preview-seed.ts";
19
20 type Env = Record<string, string | undefined>;
21
22 /** The key a preview verifies the parent's identity with; none in production. */
23 export function previewIdentityKey(env: Env = process.env): string | undefined {
24 if (!isPreviewEnv(env)) return undefined;
25 const key = (env.FORGE_APP_IDENTITY_KEY ?? "").trim();
26 return key === "" ? undefined : key;
27 }
28
29 /** The preview admin, when the parent vouches for an admin on this request. */
30 export function previewOwner(
31 app: App,
32 request: Request,
33 now = Date.now(),
34 ): User | undefined {
35 const key = app.config.previewIdentityKey;
36 if (key === undefined || key === "") return undefined;
37 const identity = request.headers.get("x-forge-identity");
38 const signature = request.headers.get("x-forge-signature");
39 if (identity === null || signature === null) return undefined;
40 const expected = Buffer.from(
41 createHmac("sha256", key).update(identity).digest("base64url"),
42 );
43 const given = Buffer.from(signature);
44 if (given.length !== expected.length || !timingSafeEqual(given, expected))
45 return undefined;
46 const parts = identity.split("|");
47 if (parts.length !== 4) return undefined;
48 const [, , role, expires] = parts as [string, string, string, string];
49 if (role !== "admin" || !/^\d+$/.test(expires) || Number(expires) <= now)
50 return undefined;
51 return previewAdmin(app);
52 }
src/spawn/preview-seed.ts added +153
@@ -0,0 +1,153 @@
1 /**
2 * A preview of the forge, seeded to be looked at (#8812). A preview starts
3 * from data with nothing an admin's pages can show, so its looks of home,
4 * /flow and /talk were blank. Here, and only in a preview, the forge makes
5 * a preview-only site admin and a small world for it: a repository with
6 * issues, a pull, a flow week (agent runs that spent) and pins on home.
7 * The parent forge's looks are taken signed in as that admin
8 * (`spawn/preview-owner.ts`).
9 *
10 * The environment is the platform's word, `FORGE_APP_ENV` (never the
11 * hostname), as for the crew (`crew/preview.ts`): only `preview` seeds,
12 * and production refuses. The admin has no password and no session: it is
13 * reached only through the parent's signed identity, so there is no secret
14 * to show or log. Once: the admin remembers that it was seeded.
15 */
16
17 import type { App } from "../app.ts";
18 import { pinPanel } from "../actions/board-pins.ts";
19 import { writeBoard } from "../actions/boards.ts";
20 import { openIssue } from "../actions/issues.ts";
21 import { openPull } from "../actions/pulls.ts";
22 import { commitToBranch, createRepo } from "../actions/repos.ts";
23 import { turnValveFor } from "../actions/valves.ts";
24 import { claimRun, createRun, finishRun } from "../core/runs.ts";
25 import { getUserSetting, setUserSetting } from "../core/settings.ts";
26 import { createUser, getUserByName, type User } from "../core/users.ts";
27 import { err, ok, type Result } from "../kernel/result.ts";
28
29 /** The preview-only site admin's name. */
30 export const PREVIEW_ADMIN = "preview-admin";
31 /** The repository the admin's pages are about. */
32 export const PREVIEW_REPO = "sample";
33 /** The board whose panels are pinned to home. */
34 const PREVIEW_BOARD = "preview";
35 const SEEDED = "preview_seeded";
36
37 type Env = Record<string, string | undefined>;
38
39 /** Whether the platform says this forge is a preview. Anything else is not. */
40 export function isPreviewEnv(env: Env = process.env): boolean {
41 return (env.FORGE_APP_ENV ?? "").trim().toLowerCase() === "preview";
42 }
43
44 /** The preview admin, when this forge has one. */
45 export function previewAdmin(app: App): User | undefined {
46 const user = getUserByName(app.db, PREVIEW_ADMIN);
47 return user?.isAdmin === true && user.kind !== "agent" ? user : undefined;
48 }
49
50 /**
51 * Seed the preview admin and its world, once. Refused (`not_preview`)
52 * anywhere the platform has not said `preview`; `seeded` is false when an
53 * earlier boot already did it.
54 */
55 export async function seedPreview(
56 app: App,
57 env: Env = process.env,
58 ): Promise<Result<{ seeded: boolean }, "not_preview" | string>> {
59 if (!isPreviewEnv(env)) return err("not_preview");
60 let admin = getUserByName(app.db, PREVIEW_ADMIN);
61 if (admin === undefined) {
62 const made = await createUser(app.db, {
63 origin: "seed",
64 trusted: true,
65 isAdmin: true,
66 allowReserved: true,
67 username: PREVIEW_ADMIN,
68 email: `${PREVIEW_ADMIN}@preview.invalid`,
69 });
70 if (!made.ok) return err(`the preview admin: ${made.error}`);
71 admin = made.value;
72 }
73 if (!admin.isAdmin || admin.kind === "agent")
74 return err("the preview admin's name is someone else's");
75 if (getUserSetting(app.db, admin.id, SEEDED) !== undefined)
76 return ok({ seeded: false });
77 const world = await seedWorld(app, admin);
78 if (!world.ok) return world;
79 setUserSetting(app.db, admin.id, SEEDED, new Date().toISOString());
80 return ok({ seeded: true });
81 }
82
83 const panel = (title: string, source: string, groupBy: string) => ({
84 title,
85 chart: "bar",
86 query: { source, aggregate: "count", groupBy },
87 w: 1,
88 h: 1,
89 });
90
91 /** The repository, its issues and pull, a week of runs, and pins on home. */
92 async function seedWorld(app: App, admin: User): Promise<Result<void, string>> {
93 const repo = await createRepo(app, admin, {
94 name: PREVIEW_REPO,
95 visibility: "public",
96 description: "A preview's sample repository",
97 });
98 if (!repo.ok) return err(`the sample repository: ${String(repo.error)}`);
99 const start = await commitToBranch(app, admin, repo.value, {

Non-blocking: if any later step fails, the SEEDED marker is never set and the next boot hits a name-taken error here forever. Resume or clean up on failure.

100 message: "Start",
101 files: [{ path: "README.md", content: "# sample\n\nA preview's sample repository.\n" }],
102 });
103 if (!start.ok) return err(`the first commit: ${JSON.stringify(start.error)}`);
104 const branch = await commitToBranch(app, admin, repo.value, {
105 branch: "greeting",
106 base: repo.value.defaultBranch,
107 message: "Say hello",
108 files: [{ path: "GREETING.md", content: "Hello.\n" }],
109 });
110 if (!branch.ok) return err(`the pull's branch: ${JSON.stringify(branch.error)}`);
111
112 const issues = [
113 { title: "The home page says hello", body: "A first thing to do." },
114 { title: "Pictures of /flow show a week", body: "So a reviewer sees the pipes." },
115 ].map((input) => openIssue(app, admin, repo.value, input));
116 const failed = issues.find((i) => !i.ok);
117 if (failed !== undefined && !failed.ok) return err(`an issue: ${String(failed.error)}`);
118 const pull = await openPull(app, admin, repo.value, {
119 title: "Say hello",
120 body: "Adds a greeting.",
121 baseBranch: repo.value.defaultBranch,
122 headBranch: "greeting",
123 });
124 if (!pull.ok) return err(`the pull: ${String(pull.error)}`);
125
126 // A flow week: agent runs that started and spent this week, and a valve
127 // turned part way, so /flow has a pipe to draw and one to turn.
128 for (const usd of [0.42, 1.18, 0.75]) {
129 const run = createRun(app.db, {
130 repoId: repo.value.id,
131 kind: "agent",
132 actorId: admin.id,
133 reason: "seed",
134 });
135 claimRun(app.db, run.id);
136 finishRun(app.db, run.id, { state: "ok", summary: "A seeded run", costUsd: usd });
137 }
138 const valve = turnValveFor(app, admin, `${PREVIEW_ADMIN}/${PREVIEW_REPO}`, 60);
139 if (!valve.ok) return err(`the valve: ${valve.error}`);
140
141 const board = await writeBoard(app, admin, PREVIEW_BOARD, {
142 title: "Preview",
143 range: "all",
144 refresh: "off",
145 panels: [panel("Runs by kind", "runs", "kind"), panel("Runs by state", "runs", "state")],
146 });
147 if (!board.ok) return err(`the board: ${board.error}`);
148 for (const p of board.value.panels) {
149 const pinned = await pinPanel(app, admin, PREVIEW_BOARD, p.title);
150 if (!pinned.ok) return err(`a pin: ${pinned.error}`);
151 }
152 return ok();
153 }
test/looks-owner.test.ts added +296
@@ -0,0 +1,296 @@
1 /**
2 * A preview of the forge looked at as its owner (#8812): `app.looksAsOwner`
3 * names pages whose phone and desktop are taken signed in as an admin even
4 * of a public app, while every other page is still taken as nobody; a
5 * preview of the forge seeds a preview-only admin and a small world, and
6 * hears the parent's signed admin as that admin — production refuses both.
7 */
8
9 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
10 import { createHmac } from "node:crypto";
11 import { writeFileSync } from "node:fs";
12 import { mkdtemp, rm } from "node:fs/promises";
13 import { tmpdir } from "node:os";
14 import { join } from "node:path";
15 import { newApp } from "../src/actions/apps.ts";
16 import { openPull } from "../src/actions/pulls.ts";
17 import { commitToBranch } from "../src/actions/repos.ts";
18 import { previewEnv } from "../src/apps/data.ts";
19 import { parseManifest } from "../src/apps/manifest.ts";
20 import { readGrant } from "../src/apps/proxy.ts";
21 import { AppsRuntime } from "../src/apps/runtime.ts";
22 import { createApp, type App } from "../src/app.ts";
23 import { listPins } from "../src/boards/pins.ts";
24 import { looksForEnv } from "../src/core/looks.ts";
25 import { listPulls } from "../src/core/pulls.ts";
26 import { getRepo } from "../src/core/repos.ts";
27 import { listRuns } from "../src/core/runs.ts";
28 import { createUser, getUserByName, type User } from "../src/core/users.ts";
29 import { readPipes } from "../src/flow/pipes.ts";
30 import { serve, type Servers } from "../src/http/server.ts";
31 import { ok } from "../src/kernel/result.ts";
32 import type { Capture, LookInput } from "../src/looks/capture.ts";
33 import { startLooks, type LooksHandle } from "../src/looks/service.ts";
34 import { previewIdentityKey } from "../src/spawn/preview-owner.ts";
35 import { PREVIEW_ADMIN, PREVIEW_REPO, seedPreview } from "../src/spawn/preview-seed.ts";
36 import { quietLogs } from "./helpers/globals.ts";
37
38 quietLogs();
39
40 const PNG = Buffer.from(
41 "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==",
42 "base64",
43 );
44
45 describe("app.looksAsOwner in forge.json", () => {
46 const manifest = (app: Record<string, unknown>) =>
47 parseManifest(JSON.stringify({ app: { run: "bun x", ...app } }), undefined);
48
49 test("names pages of app.looks; absent is none", () => {
50 const named = manifest({ looks: ["/", "/flow"], looksAsOwner: ["/flow"] });
51 expect(named.ok && named.value.looksAsOwner).toEqual(["/flow"]);
52 const absent = manifest({ looks: ["/"] });
53 expect(absent.ok && absent.value.looksAsOwner).toEqual([]);
54 });
55
56 test("a page app.looks does not name, or a path that is not one, is refused", () => {
57 const stray = manifest({ looks: ["/"], looksAsOwner: ["/flow"] });
58 expect(!stray.ok && stray.error.kind === "invalid" && stray.error.message).toContain(
59 "app.looksAsOwner names /flow",
60 );
61 for (const bad of ["flow", "//evil.example", "/a/../b", 7])
62 expect(manifest({ looks: ["/"], looksAsOwner: [bad] }).ok).toBe(false);
63 });
64 });
65
66 describe("the preview seed", () => {
67 let dataDir: string;
68 let app: App;
69
70 beforeAll(async () => {
71 dataDir = await mkdtemp(join(tmpdir(), "forge-preview-seed-"));
72 app = createApp({ dataDir, port: 0 });
73 });
74 afterAll(async () => {
75 app.close();
76 await rm(dataDir, { recursive: true, force: true });
77 });
78
79 test("production, or no word from the platform, refuses it and makes no one", async () => {
80 for (const env of [{ FORGE_APP_ENV: "production" }, {}, { FORGE_APP_ENV: "previewish" }]) {
81 const refused = await seedPreview(app, env);
82 expect(refused.ok).toBe(false);
83 expect(!refused.ok && refused.error).toBe("not_preview");
84 }
85 expect(getUserByName(app.db, PREVIEW_ADMIN)).toBeUndefined();
86 expect(previewIdentityKey({ FORGE_APP_ENV: "production", FORGE_APP_IDENTITY_KEY: "k" })).toBeUndefined();
87 expect(previewIdentityKey({ FORGE_APP_IDENTITY_KEY: "k" })).toBeUndefined();
88 expect(previewIdentityKey({ FORGE_APP_ENV: "preview", FORGE_APP_IDENTITY_KEY: "k" })).toBe("k");
89 });
90
91 test("a preview seeds an admin with no password, a repository with issues and a pull, a flow week and pins — once", async () => {
92 const seeded = await seedPreview(app, { FORGE_APP_ENV: "preview" });
93 expect(seeded).toEqual(ok({ seeded: true }));
94 const admin = getUserByName(app.db, PREVIEW_ADMIN)!;
95 expect(admin.isAdmin).toBe(true);
96 const hash = app.db
97 .query<{ password_hash: string }, [string]>("SELECT password_hash FROM user WHERE id = ?1")
98 .get(admin.id);
99 expect(hash?.password_hash).toBe("");
100 expect(app.db.query("SELECT COUNT(*) AS n FROM session").get()).toEqual({ n: 0 });
101 const repo = getRepo(app.db, PREVIEW_ADMIN, PREVIEW_REPO)!;
102 expect(repo).toBeDefined();
103 const issues = app.db
104 .query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM issue WHERE repo_id = ?1 AND kind = 'issue'")
105 .get(repo.id);
106 expect(issues?.n).toBe(2);
107 expect(listPulls(app.db, repo.id).length).toBe(1);
108 expect(listRuns(app.db, repo.id, { kind: "agent" }).every((r) => r.state === "ok")).toBe(true);
109 const pipes = readPipes(app.db, admin.id);
110 expect(pipes.usd).toBeGreaterThan(0);
111 expect(pipes.valves.find((v) => v.slug === `${PREVIEW_ADMIN}/${PREVIEW_REPO}`)?.level).toBe(60);
112 expect(listPins(app.db, admin.id).length).toBe(2);
113
114 expect(await seedPreview(app, { FORGE_APP_ENV: "preview" })).toEqual(ok({ seeded: false }));
115 expect(listPins(app.db, admin.id).length).toBe(2);
116 }, 30_000);
117 });
118
119 describe("a preview hears its parent's admin as the preview admin", () => {
120 const KEY = "the-parents-identity-key";
121 const dirs: string[] = [];
122 const apps: App[] = [];
123 const servers: Servers[] = [];
124
125 async function forge(previewIdentityKey?: string): Promise<Servers> {
126 const dataDir = await mkdtemp(join(tmpdir(), "forge-preview-owner-"));
127 dirs.push(dataDir);
128 const app = createApp({ dataDir, port: 0, ...(previewIdentityKey === undefined ? {} : { previewIdentityKey }) });
129 apps.push(app);
130 const seeded = await seedPreview(app, { FORGE_APP_ENV: "preview" });
131 if (!seeded.ok) throw new Error(seeded.error);
132 const s = await serve(app);
133 servers.push(s);
134 return s;
135 }
136 const identity = (role: string, key = KEY, expires = Date.now() + 60_000) => {
137 const said = `u1|trevato|${role}|${expires}`;
138 return {
139 "x-forge-identity": said,
140 "x-forge-signature": createHmac("sha256", key).update(said).digest("base64url"),
141 };
142 };
143 const flow = (s: Servers, headers: Record<string, string>) =>
144 fetch(`${s.url}/flow`, { headers, redirect: "manual" });
145
146 afterAll(async () => {
147 for (const s of servers) s.stop();
148 for (const a of apps) a.close();
149 for (const d of dirs) await rm(d, { recursive: true, force: true });
150 });
151
152 test("a signed admin is the preview admin; a reader, a forged or a stale signature is nobody", async () => {
153 const preview = await forge(KEY);
154 const signedIn = await flow(preview, identity("admin"));
155 expect(signedIn.status).toBe(200);
156 const page = await signedIn.text();
157 expect(page).toContain("<h1>Flow</h1>");
158 expect(page).toContain(`${PREVIEW_ADMIN}/${PREVIEW_REPO}`);
159 // No session is made for it: nothing to show, nothing to log.
160 expect(signedIn.headers.get("set-cookie") ?? "").not.toContain("forge_session");
161 for (const headers of [
162 identity("user"),
163 identity("admin", "not-the-key"),
164 identity("admin", KEY, Date.now() - 1),
165 {},
166 ])
167 expect((await flow(preview, headers)).status).not.toBe(200);
168 }, 30_000);
169
170 test("a mutation it rides is held to the same-origin rule", async () => {
171 const preview = servers[0]!;
172 const turned = await fetch(`${preview.url}/flow/valve`, {
173 method: "POST",
174 headers: { ...identity("admin"), origin: "https://elsewhere.example" },
175 body: new URLSearchParams({ repo: `${PREVIEW_ADMIN}/${PREVIEW_REPO}`, level: "10" }),
176 redirect: "manual",
177 });
178 expect(turned.status).toBe(403);
179 });
180
181 test("production, never handed the key, hears nobody", async () => {
182 const production = await forge(undefined);
183 expect((await flow(production, identity("admin"))).status).not.toBe(200);
184 }, 30_000);
185 });
186
187 describe("looks of a public app", () => {
188 let dataDir: string;
189 let app: App;
190 let servers: Servers;
191 let runtime: AppsRuntime;
192 let looks: LooksHandle;
193 let alice: User;
194 const calls: LookInput[] = [];
195 const fake: Capture = async (input) => {
196 calls.push(input);
197 writeFileSync(input.out, PNG);
198 return ok({ entries: {} });
199 };
200
201 beforeAll(async () => {
202 dataDir = await mkdtemp(join(tmpdir(), "forge-looks-owner-"));
203 app = createApp({ dataDir, port: 0 });
204 servers = await serve(app);
205 runtime = new AppsRuntime(app, {
206 domain: "apps.test",
207 scheme: "http",
208 publicPort: servers.port,
209 forgeUrl: servers.url,
210 concurrency: 2,
211 });
212 await runtime.start();
213 looks = startLooks(app, { localUrl: servers.url, capture: fake });
214 const a = await createUser(app.db, {
215 origin: "seed",
216 trusted: true,
217 username: "alice",
218 email: "[email protected]",
219 password: "a-good-passphrase",
220 adminIfFirst: true,
221 });
222 if (!a.ok) throw new Error(a.error);
223 alice = a.value;
224 }, 30_000);
225
226 afterAll(async () => {
227 await looks.stop();
228 await runtime.stop();
229 servers.stop();
230 app.close();
231 await rm(dataDir, { recursive: true, force: true });
232 });
233
234 test("a page listed as owner carries the admin grant; one not listed is still taken as nobody", async () => {
235 const made = await newApp(app, alice, { name: "plaza", visibility: "public" });
236 if (!made.ok) throw new Error(String(made.error));
237 const { repo, app: record } = made.value;
238 expect(record.access).toBe("public");
239 const branch = await commitToBranch(app, alice, repo, {
240 branch: "pages",
241 base: repo.defaultBranch,
242 message: "Picture /flow as the owner",
243 files: [
244 {
245 path: "forge.json",
246 content: JSON.stringify({
247 app: {
248 run: "bun run src/server.ts",
249 health: "/healthz",
250 looks: ["/", "/flow"],
251 looksAsOwner: ["/flow"],
252 },
253 }),
254 },
255 ],
256 });
257 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
258 const opened = await openPull(app, alice, repo, {
259 title: "Pages",
260 baseBranch: repo.defaultBranch,
261 headBranch: "pages",
262 });
263 if (!opened.ok) throw new Error(String(opened.error));
264 const env = previewEnv(opened.value.number);
265 const commit = branch.value.commitOid;
266 const deadline = Date.now() + 90_000;
267 const light = () =>
268 looksForEnv(app.db, record.id, env).filter((l) => l.commitOid === commit && l.theme === "light");
269 while (light().length < 6 && Date.now() < deadline) await Bun.sleep(100);
270 await looks.idle();
271 const rows = light();
272 const taken = (page: string, kind: string) =>
273 rows.find((l) => l.page === page && l.kind === kind)?.takenAs;
274 expect(taken("/flow", "phone")).toBe("owner");
275 expect(taken("/flow", "desktop")).toBe("owner");
276 expect(taken("/flow", "thumb")).toBe("nobody");
277 expect(taken("/", "phone")).toBe("nobody");
278 expect(taken("/", "desktop")).toBe("nobody");
279
280 const grantOf = (page: string, width: number) => {
281 const call = calls.find(
282 (c) =>
283 c.url.includes(`plaza--${opened.value.number}.`) &&
284 new URL(c.url).searchParams.get("to") === page &&
285 c.width === width &&
286 c.dark !== true &&
287 !c.out.includes("thumb"),
288 )!;
289 return readGrant(dataDir, new URL(call.url).searchParams.get("t") ?? "", { app: record, env });
290 };
291 expect(grantOf("/flow", 390)?.who).toEqual({ id: alice.id, name: "alice", role: "admin" });
292 expect(grantOf("/flow", 1280)?.who).toEqual({ id: alice.id, name: "alice", role: "admin" });
293 expect(grantOf("/", 390)?.who).toBeUndefined();
294 expect(grantOf("/", 1280)?.who).toBeUndefined();
295 }, 120_000);
296 });
test/signup-gate-parent.test.ts +1
@@ -115,6 +115,7 @@ test("every caller of createUser says where its account comes from", async () =>
115115 "src/actions/spawn.ts": ["spawn", "spawn"],
116116 "src/actions/users.ts": ["signup", "passkey"],
117117 "src/chaos/world.ts": ["seed"],
118 "src/spawn/preview-seed.ts": ["seed"],
118119 "src/web/pages/claim.ts": ["claim"],
119120 "src/web/pages/everyday.ts": ["everyday"],
120121 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs