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

Merged opened this

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

Merged

scout/forge:scout/6268-forge-crew-hands-a-reader-the-app-s-othe → main 1 commit fork

  • Merged. scout/6268-forge-crew-hands-a-reader-the-app-s-othe was merged into main as 1f41b01.

Review

Sign in to review this change.
3 files changed +176 −2
src/apps/proxy.ts +42 −1
@@ -37,6 +37,7 @@ import { loadKey } from "../kernel/secret.ts";
3737 import { previewEnv, type EnvName } from "./data.ts";
3838 import type { AppsRuntime, EnvStatus } from "./runtime.ts";
3939 import { crewNow, tellLink } from "./crew.ts";
40 import { sideOf, type Side } from "./side.ts";
4041 import { feedback, mintProof } from "./feedback.ts";
4142 import { clientBucket } from "../http/peer.ts";
4243 import { DOOR_PULL_LIMIT, doorPage, type DoorPullLine } from "./door.ts";
@@ -479,6 +480,38 @@ function doorPulls(
479480 });
480481 }
481482
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
482515 /** Whether the person a grant names may enter this app now. */
483516 function stillEntitled(
484517 runtime: AppsRuntime,
@@ -677,8 +710,16 @@ export async function proxyApp(
677710 target.app,
678711 signed?.who?.id,
679712 );
713 const side =
714 signed?.who === undefined
715 ? null
716 : timePhase(request, "side", () => sideFor(runtime, target, signed.who));
680717 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 }),
682723 );
683724 return withPhases(
684725 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
313313 ];
314314 const reads: Record<string, string[]> = {
315315 "/__forge/": ["session", "app", "pull", "serving", "pulls", "render"],
316 "/__forge/crew": ["session", "app", "pull", "working", "render"],
316 "/__forge/crew": ["session", "app", "pull", "working", "side", "render"],
317317 };
318318 for (const [host, cookie] of hosts)
319319 for (const [path, names] of Object.entries(reads)) {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs