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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/8812-a-preview-of-the-forge-can-be-looked-at → main 5 commits fork 1 thread · 1 unresolved
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 | ||
| 1260 | 1260 | writers' alone. The thumb is taken as nobody, for the tiles strangers see. A |
| 1261 | 1261 | public app on a public repository is the world's to see, so all three of its |
| 1262 | 1262 | 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 | |
| 1264 | 1268 | app draws after that is not in it — and one at a time, forge-wide, bounded |
| 1265 | 1269 | in time and memory. A look is best-effort: it never blocks a check or fails |
| 1266 | 1270 | 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 @@ | ||
| 4 | 4 | ], |
| 5 | 5 | "app": { |
| 6 | 6 | "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"], | |
| 7 | 8 | "run": "bun run src/main.ts", |
| 8 | 9 | "install": "", |
| 9 | 10 | "health": "/healthz", |
src/app.ts +7
| @@ -64,6 +64,13 @@ export interface ForgeConfig { | ||
| 64 | 64 | * in by the platform. Unset, those headers are a claim and are ignored. |
| 65 | 65 | */ |
| 66 | 66 | 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; | |
| 67 | 74 | /** |
| 68 | 75 | * Let anyone, not only an admin, migrate from a host inside the network |
| 69 | 76 | * (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 { | ||
| 39 | 39 | * before this field existed. |
| 40 | 40 | */ |
| 41 | 41 | 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; | |
| 42 | 49 | /** |
| 43 | 50 | * Set when `app.kind` is `"service"`: declared TCP ports, always on, |
| 44 | 51 | * a placement preference and synced volumes. `null` for an ordinary |
| @@ -142,6 +149,8 @@ export function parseManifest( | ||
| 142 | 149 | ); |
| 143 | 150 | const looks = looksField(app); |
| 144 | 151 | if (!looks.ok) return looks; |
| 152 | const looksAsOwner = looksAsOwnerField(app, looks.value); | |
| 153 | if (!looksAsOwner.ok) return looksAsOwner; | |
| 145 | 154 | const egress = egressField(parsed.value); |
| 146 | 155 | if (!egress.ok) return egress; |
| 147 | 156 | const limits = limitsField(app, "app"); |
| @@ -174,6 +183,7 @@ export function parseManifest( | ||
| 174 | 183 | ready: ready ?? READY_DEFAULT, |
| 175 | 184 | tty: tty.value || null, |
| 176 | 185 | looks: looks.value, |
| 186 | looksAsOwner: looksAsOwner.value, | |
| 177 | 187 | service, |
| 178 | 188 | egress: egress.value, |
| 179 | 189 | limits: limits.value, |
| @@ -268,28 +278,58 @@ function limitsField( | ||
| 268 | 278 | function looksField(app: Json): Result<readonly string[], ManifestError> { |
| 269 | 279 | const value = app["looks"]; |
| 270 | 280 | 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> { | |
| 271 | 312 | 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`); | |
| 273 | 314 | 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`); | |
| 276 | 316 | const paths: string[] = []; |
| 277 | 317 | for (const item of value) { |
| 278 | 318 | 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`); | |
| 280 | 320 | if (item.length > LOOK_PATH_MAX) |
| 281 | 321 | 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`, | |
| 283 | 323 | ); |
| 284 | 324 | if (!item.startsWith("/") || item.startsWith("//")) |
| 285 | 325 | 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)`, | |
| 287 | 327 | ); |
| 288 | 328 | if (item.includes("..")) |
| 289 | return invalid("forge.json: app.looks entries must not contain .."); | |
| 329 | return invalid(`forge.json: ${field} entries must not contain ..`); | |
| 290 | 330 | if (/[\x00-\x1f\x7f]/.test(item)) |
| 291 | 331 | return invalid( |
| 292 | "forge.json: app.looks entries must not contain control characters", | |
| 332 | `forge.json: ${field} entries must not contain control characters`, | |
| 293 | 333 | ); |
| 294 | 334 | paths.push(item); |
| 295 | 335 | } |
src/apps/runtime.ts +4
| @@ -213,6 +213,8 @@ export interface EnvStatus { | ||
| 213 | 213 | * as `tty`. |
| 214 | 214 | */ |
| 215 | 215 | 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; | |
| 216 | 218 | /** `false` when its `forge.json` turns the forge pill off; unset is on. */ |
| 217 | 219 | readonly toolbar?: boolean | undefined; |
| 218 | 220 | /** |
| @@ -537,6 +539,7 @@ export class AppsRuntime { | ||
| 537 | 539 | createdAt: state.live.createdAt, |
| 538 | 540 | tty: state.live.manifest.tty, |
| 539 | 541 | looks: state.live.manifest.looks, |
| 542 | looksAsOwner: state.live.manifest.looksAsOwner, | |
| 540 | 543 | toolbar: state.live.manifest.toolbar, |
| 541 | 544 | ...(state.live.deployStartedAt === null |
| 542 | 545 | ? {} |
| @@ -574,6 +577,7 @@ export class AppsRuntime { | ||
| 574 | 577 | createdAt: state.live.createdAt, |
| 575 | 578 | tty: state.live.manifest.tty, |
| 576 | 579 | looks: state.live.manifest.looks, |
| 580 | looksAsOwner: state.live.manifest.looksAsOwner, | |
| 577 | 581 | toolbar: state.live.manifest.toolbar, |
| 578 | 582 | ...(state.live.deployStartedAt === null |
| 579 | 583 | ? {} |
src/http/server.ts +7
| @@ -502,6 +502,7 @@ import { stopCellChildren } from "../spawn/cell.ts"; | ||
| 502 | 502 | import { portInsteadOfSocket, tcpAddress } from "../git/hook-address.ts"; |
| 503 | 503 | import { answerFromCell, closeHostedChildren, resolveHostedChild, resolveHostedChildApp, socketToCell } from "../spawn/host.ts"; |
| 504 | 504 | import { getVerifiedDomain } from "../core/domains.ts"; |
| 505 | import { previewOwner } from "../spawn/preview-owner.ts"; | |
| 505 | 506 | |
| 506 | 507 | // ---- auth ---- |
| 507 | 508 | |
| @@ -517,6 +518,12 @@ async function resolveAuth(app: App, request: Request): Promise<Auth> { | ||
| 517 | 518 | if (user) return { user, scope: "admin", via: "edge" }; |
| 518 | 519 | } |
| 519 | 520 | |
| 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 | ||
| 520 | 527 | const session = cookieValue(request, "forge_session"); |
| 521 | 528 | if (session !== undefined) { |
| 522 | 529 | const found = sessionOf(app.db, session); |
src/looks/service.ts +11 −4
| @@ -372,8 +372,15 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle { | ||
| 372 | 372 | // A private app turns a signed-out visitor away to sign in, on another |
| 373 | 373 | // host: its thumb is taken through the grant door as the owner, like |
| 374 | 374 | // 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")); | |
| 377 | 384 | const owner = getUserById(app.db, repo.ownerId); |
| 378 | 385 | const asOwner: GrantWho | undefined = |
| 379 | 386 | owner === undefined |
| @@ -390,7 +397,7 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle { | ||
| 390 | 397 | { app: record, env: item.env }, |
| 391 | 398 | GRANT_TTL_MS, |
| 392 | 399 | Date.now(), |
| 393 | asNobody(kind) ? undefined : asOwner, | |
| 400 | asNobody(kind, page) ? undefined : asOwner, | |
| 394 | 401 | ); |
| 395 | 402 | const url = `http://${host}:${port}/__forge/grant?t=${encodeURIComponent(token)}&to=${encodeURIComponent(page)}`; |
| 396 | 403 | const path = lookPath(item.appId, item.env, item.commitOid, page, kind, theme); |
| @@ -445,7 +452,7 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle { | ||
| 445 | 452 | ...row, |
| 446 | 453 | kind, |
| 447 | 454 | path, |
| 448 | takenAs: asNobody(kind) ? "nobody" : "owner", | |
| 455 | takenAs: asNobody(kind, page) ? "nobody" : "owner", | |
| 449 | 456 | }); |
| 450 | 457 | } else { |
| 451 | 458 | rmSync(staged, { force: true }); |
src/main.ts +15
| @@ -21,6 +21,8 @@ import { offerStartingBoards } from "./actions/boards.ts"; | ||
| 21 | 21 | import { syncAllPillars } from "./actions/pillars.ts"; |
| 22 | 22 | import { bootHostedChildren } from "./spawn/host.ts"; |
| 23 | 23 | 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"; | |
| 24 | 26 | import { startHeartbeat } from "./spawn/heartbeat.ts"; |
| 25 | 27 | import { startUpstreamWatch } from "./spawn/upstream.ts"; |
| 26 | 28 | import { startReachWatch } from "./spawn/reach.ts"; |
| @@ -217,6 +219,8 @@ watchStalls(loopLog, undefined, undefined, undefined, (late, at) => { | ||
| 217 | 219 | if (stall !== null) loopLog.warn(stallLine(stall)); |
| 218 | 220 | }); |
| 219 | 221 | |
| 222 | const previewKey = previewIdentityKey(process.env); | |
| 223 | ||
| 220 | 224 | const app = createApp({ |
| 221 | 225 | dataDir, |
| 222 | 226 | // A new forge starts with signup = invite (FORGE_SIGNUP below still wins). |
| @@ -228,6 +232,9 @@ const app = createApp({ | ||
| 228 | 232 | : { host: process.env.FORGE_HOST }), |
| 229 | 233 | ...(canonicalUrl === "" ? {} : { url: new URL(canonicalUrl).origin }), |
| 230 | 234 | ...(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 }), | |
| 231 | 238 | // Hosted, the host says how this forge's terminal is reached; printed |
| 232 | 239 | // verbatim wherever the forge tells people how to connect. |
| 233 | 240 | ...(hosted && (process.env.FORGE_APP_SSH ?? "").trim() !== "" |
| @@ -505,6 +512,14 @@ void bootHostedChildren(app); | ||
| 505 | 512 | // A spawned child's first boot: its direction, opened once for its planner |
| 506 | 513 | // (spawn/first-boot.ts). A forge that was not spawned is untouched. |
| 507 | 514 | 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) })); | |
| 508 | 523 | // A spawned child reports its standing to its parent (spawn/heartbeat.ts). |
| 509 | 524 | const heartbeatTimer = startHeartbeat(app); |
| 510 | 525 | // ...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, { | |
| 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 () => | ||
| 115 | 115 | "src/actions/spawn.ts": ["spawn", "spawn"], |
| 116 | 116 | "src/actions/users.ts": ["signup", "passkey"], |
| 117 | 117 | "src/chaos/world.ts": ["seed"], |
| 118 | "src/spawn/preview-seed.ts": ["seed"], | |
| 118 | 119 | "src/web/pages/claim.ts": ["claim"], |
| 119 | 120 | "src/web/pages/everyday.ts": ["everyday"], |
| 120 | 121 | }); |
new above
offline
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.