Public: anyone can read this; the crew can change it.
`/__forge/crew` hands a reader the app's other side, and a guest of a private app gets none #6277
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6268-forge-crew-hands-a-reader-the-app-s-othe → main 1 commit fork
3 files
changed +176
−2
src/apps/proxy.ts +42 −1
| @@ -37,6 +37,7 @@ import { loadKey } from "../kernel/secret.ts"; | ||
| 37 | 37 | import { previewEnv, type EnvName } from "./data.ts"; |
| 38 | 38 | import type { AppsRuntime, EnvStatus } from "./runtime.ts"; |
| 39 | 39 | import { crewNow, tellLink } from "./crew.ts"; |
| 40 | import { sideOf, type Side } from "./side.ts"; | |
| 40 | 41 | import { feedback, mintProof } from "./feedback.ts"; |
| 41 | 42 | import { clientBucket } from "../http/peer.ts"; |
| 42 | 43 | import { DOOR_PULL_LIMIT, doorPage, type DoorPullLine } from "./door.ts"; |
| @@ -479,6 +480,38 @@ function doorPulls( | ||
| 479 | 480 | }); |
| 480 | 481 | } |
| 481 | 482 | |
| 483 | /** | |
| 484 | * The pill's other side, for a signed-in reader of the repository only: the | |
| 485 | * pull titles it may name are the reader's own to see (`openPulls`). | |
| 486 | */ | |
| 487 | function sideFor( | |
| 488 | runtime: AppsRuntime, | |
| 489 | target: AppTarget, | |
| 490 | who: GrantWho | undefined, | |
| 491 | ): Side | null { | |
| 492 | if (who === undefined) return null; | |
| 493 | const db = runtime.app.db; | |
| 494 | const viewer = getUserById(db, who.id); | |
| 495 | const repo = getRepoById(db, target.app.repoId); | |
| 496 | if (viewer === undefined || !isActive(viewer) || repo === undefined) return null; | |
| 497 | if (!canRead(db, repo, viewer)) return null; | |
| 498 | const app = { url: runtime.urlFor(target.app, "production") }; | |
| 499 | if (target.env !== "production") return sideOf(app, target.env, []); | |
| 500 | const previews = openPulls(db, repo, viewer, 50).flatMap((p) => { | |
| 501 | const env = previewEnv(p.number); | |
| 502 | const status = runtime.envStatus(target.app, env); | |
| 503 | if (status === undefined) return []; | |
| 504 | const state = | |
| 505 | status.state === "running" | |
| 506 | ? "running" | |
| 507 | : status.state === "asleep" || status.dormant !== undefined | |
| 508 | ? "asleep" | |
| 509 | : status.state; | |
| 510 | return [{ number: p.number, title: p.title, state, url: runtime.urlFor(target.app, env) }]; | |
| 511 | }); | |
| 512 | return sideOf(app, target.env, previews); | |
| 513 | } | |
| 514 | ||
| 482 | 515 | /** Whether the person a grant names may enter this app now. */ |
| 483 | 516 | function stillEntitled( |
| 484 | 517 | runtime: AppsRuntime, |
| @@ -677,8 +710,16 @@ export async function proxyApp( | ||
| 677 | 710 | target.app, |
| 678 | 711 | signed?.who?.id, |
| 679 | 712 | ); |
| 713 | const side = | |
| 714 | signed?.who === undefined | |
| 715 | ? null | |
| 716 | : timePhase(request, "side", () => sideFor(runtime, target, signed.who)); | |
| 680 | 717 | const body = timePhase(request, "render", () => |
| 681 | JSON.stringify({ ...crew, ...(tell === undefined ? {} : { tell }) }), | |
| 718 | JSON.stringify({ | |
| 719 | ...crew, | |
| 720 | ...(tell === undefined ? {} : { tell }), | |
| 721 | ...(side === null ? {} : { side }), | |
| 722 | }), | |
| 682 | 723 | ); |
| 683 | 724 | return withPhases( |
| 684 | 725 | request, |
test/apps-crew-side.test.ts added +133
| @@ -0,0 +1,133 @@ | ||
| 1 | /** `/__forge/crew` hands a reader the app's other side, and nobody else (#6268). */ | |
| 2 | ||
| 3 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 4 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 5 | import { tmpdir } from "node:os"; | |
| 6 | import { join } from "node:path"; | |
| 7 | import { createApp, type App } from "../src/app.ts"; | |
| 8 | import { newApp } from "../src/actions/apps.ts"; | |
| 9 | import { signGrant, type AppTarget } from "../src/apps/proxy.ts"; | |
| 10 | import { AppsRuntime } from "../src/apps/runtime.ts"; | |
| 11 | import { getAppByRepo, type AppRecord } from "../src/core/apps.ts"; | |
| 12 | import { createIssue } from "../src/core/issues.ts"; | |
| 13 | import { createUser } from "../src/core/users.ts"; | |
| 14 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 15 | import { quietLogs } from "./helpers/globals.ts"; | |
| 16 | ||
| 17 | quietLogs(); | |
| 18 | ||
| 19 | const DOMAIN = "apps.test"; | |
| 20 | let dataDir: string; | |
| 21 | let app: App; | |
| 22 | let servers: Servers; | |
| 23 | let runtime: AppsRuntime; | |
| 24 | let alice: string; | |
| 25 | let bob: string; | |
| 26 | let vault: AppRecord; | |
| 27 | let preview: number; | |
| 28 | ||
| 29 | beforeAll(async () => { | |
| 30 | dataDir = await mkdtemp(join(tmpdir(), "forge-side-")); | |
| 31 | app = createApp({ dataDir, port: 0, host: "forge.test" }); | |
| 32 | servers = await serve(app); | |
| 33 | runtime = new AppsRuntime(app, { | |
| 34 | domain: DOMAIN, | |
| 35 | scheme: "http", | |
| 36 | publicPort: servers.port, | |
| 37 | forgeUrl: servers.url, | |
| 38 | concurrency: 1, | |
| 39 | }); | |
| 40 | await runtime.start(); | |
| 41 | const user = async (username: string, adminIfFirst: boolean) => { | |
| 42 | const u = await createUser(app.db, { | |
| 43 | origin: "seed", | |
| 44 | trusted: true, | |
| 45 | username, | |
| 46 | email: `${username}@forge.local`, | |
| 47 | password: "a-good-passphrase", | |
| 48 | adminIfFirst, | |
| 49 | }); | |
| 50 | if (!u.ok) throw new Error(u.error); | |
| 51 | return u.value; | |
| 52 | }; | |
| 53 | const a = await user("alice", true); | |
| 54 | alice = a.id; | |
| 55 | bob = (await user("bob", false)).id; | |
| 56 | const made = await newApp(app, a, { name: "vault", description: "x", visibility: "private" }); | |
| 57 | if (!made.ok) throw new Error(String(made.error)); | |
| 58 | vault = getAppByRepo(app.db, made.value.repo.id)!; | |
| 59 | const p = createIssue(app.db, { repoId: vault.repoId, authorId: alice, title: "secret plan", kind: "pull" }); | |
| 60 | if (!p.ok) throw new Error(p.error); | |
| 61 | preview = p.value.number; | |
| 62 | const id = ( | |
| 63 | app.db.query("SELECT id FROM issue WHERE repo_id = ? AND number = ?").get(vault.repoId, preview) as { id: string } | |
| 64 | ).id; | |
| 65 | app.db | |
| 66 | .query("INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid) VALUES (?, 'main', 'x', 'b', 'h')") | |
| 67 | .run(id); | |
| 68 | (runtime as unknown as { envs: Map<string, unknown> }).envs.set(`${vault.id}/pull-${preview}`, { | |
| 69 | app: vault, | |
| 70 | env: `pull-${preview}`, | |
| 71 | chain: Promise.resolve(), | |
| 72 | restarts: [], | |
| 73 | dormant: { since: new Date().toISOString(), idleSince: new Date().toISOString(), commitOid: "h" }, | |
| 74 | }); | |
| 75 | }, 120_000); | |
| 76 | ||
| 77 | afterAll(async () => { | |
| 78 | await runtime.stop(); | |
| 79 | servers.stop(); | |
| 80 | app.close(); | |
| 81 | await rm(dataDir, { recursive: true, force: true }); | |
| 82 | }); | |
| 83 | ||
| 84 | const crew = async (host: string, as?: { id: string; name: string }) => { | |
| 85 | const cookie = | |
| 86 | as === undefined | |
| 87 | ? undefined | |
| 88 | : `forge_grant_${vault.id.slice(-8)}=${signGrant( | |
| 89 | dataDir, | |
| 90 | { app: vault, env: host.includes("--") ? `pull-${preview}` : "production" } as AppTarget, | |
| 91 | 60_000, | |
| 92 | Date.now(), | |
| 93 | { id: as.id, name: as.name, role: "admin" }, | |
| 94 | )}`; | |
| 95 | const res = await fetch(`${servers.url}/__forge/crew`, { | |
| 96 | headers: { host, ...(cookie === undefined ? {} : { cookie }) }, | |
| 97 | }); | |
| 98 | expect(res.status).toBe(200); | |
| 99 | const text = await res.text(); | |
| 100 | return { text, json: JSON.parse(text) as { side?: { label: string; url: string } } }; | |
| 101 | }; | |
| 102 | ||
| 103 | const prod = `vault.${DOMAIN}`; | |
| 104 | const pre = () => `vault--${preview}.${DOMAIN}`; | |
| 105 | ||
| 106 | test("production names the newest preview a reader may see", async () => { | |
| 107 | const { json } = await crew(prod, { id: alice, name: "alice" }); | |
| 108 | expect(json.side).toEqual({ | |
| 109 | label: `Newest preview: #${preview} secret plan`, | |
| 110 | url: runtime.urlFor(vault, `pull-${preview}`), | |
| 111 | }); | |
| 112 | }); | |
| 113 | ||
| 114 | test("a preview names production to a reader", async () => { | |
| 115 | const { json } = await crew(pre(), { id: alice, name: "alice" }); | |
| 116 | expect(json.side).toEqual({ label: "Open on production", url: runtime.urlFor(vault, "production") }); | |
| 117 | }); | |
| 118 | ||
| 119 | test("a guest of a private app gets no side, and no pull title", async () => { | |
| 120 | for (const host of [prod, pre()]) { | |
| 121 | const { text, json } = await crew(host); | |
| 122 | expect(json.side).toBeUndefined(); | |
| 123 | expect(text).not.toContain("secret plan"); | |
| 124 | } | |
| 125 | }); | |
| 126 | ||
| 127 | test("a signed-in stranger of a private app gets no side, and no pull title", async () => { | |
| 128 | for (const host of [prod, pre()]) { | |
| 129 | const { text, json } = await crew(host, { id: bob, name: "bob" }); | |
| 130 | expect(json.side).toBeUndefined(); | |
| 131 | expect(text).not.toContain("secret plan"); | |
| 132 | } | |
| 133 | }); |
test/apps-door.test.ts +1 −1
| @@ -313,7 +313,7 @@ test("the door and the crew line name the phases they read, signed in, on produc | ||
| 313 | 313 | ]; |
| 314 | 314 | const reads: Record<string, string[]> = { |
| 315 | 315 | "/__forge/": ["session", "app", "pull", "serving", "pulls", "render"], |
| 316 | "/__forge/crew": ["session", "app", "pull", "working", "render"], | |
| 316 | "/__forge/crew": ["session", "app", "pull", "working", "side", "render"], | |
| 317 | 317 | }; |
| 318 | 318 | for (const [host, cookie] of hosts) |
| 319 | 319 | for (const [path, names] of Object.entries(reads)) { |
new above
offline