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

The toolbar on a public app that is not yours says Make it yours, and one tap forks it into your forge #11482

opened this

Check · Review · Land · Live — Pushed since the last review. Next: reviewer, pushed since the last review.

Ready to land

scout/forge:scout/11463-the-toolbar-on-a-public-app-that-is-not → main 3 commits fork 3 threads · 3 unresolved

Review

Sign in to review this change.
7 files changed +263 −8
docs/features/forge-toolbar.md +11 −1
@@ -9,6 +9,16 @@ preview), so you always know where you are and can get back to the forge.
99 byte. A page that already carries the tag is not given a second.
1010 - **What it says:** the app, the environment and, on a preview, a link
1111 "Preview of pull #N" to the pull's page on the forge; Home goes to the forge.
12 - **Make it yours:** on a public app, a reader who is not on its crew sees
13 one more link, "Make it yours", to the forge's own make-it-yours page for
14 that app (no new route), opened in the same tab. Signed out, that page asks
15 for sign-in and returns to itself. The owner, any member who can write, a private app and a sealed
16 pull's preview see none. A read-only member of a public repository is
17 offered it (they cannot write, so there is something to fork); a suspended,
18 removed or unknown account is not, while a signed-out reader is. The pill is
19 a page-only surface: the terminal already has make-it-yours
20 (`src/tui/screens/yours.ts`). Who the reader is comes from the app's own grant,
21 read by `/__forge/crew`, never from the page.
1222 - **What a note carries:** a note sent from the pill also carries where it came
1323 from, folded at the foot of the issue under "Where this came from": the
1424 build the app is serving (from the forge's own record of the running
@@ -29,4 +39,4 @@ preview), so you always know where you are and can get back to the forge.
2939 pill out; the app still works.
3040 - **Off:** `"toolbar": false` in `forge.json`.
3141
32 Tests: `test/toolbar-injection.test.ts`, `test/apps-toolbar.test.ts`, `test/note-context.test.ts`, `test/apps-feedback.test.ts`.
42 Tests: `test/toolbar-injection.test.ts`, `test/apps-toolbar.test.ts`, `test/note-context.test.ts`, `test/apps-feedback.test.ts`, `test/apps-toolbar-yours.test.ts`.
src/apps/crew.ts +24 −1
@@ -11,7 +11,7 @@ import type { AppRecord } from "../core/apps.ts";
1111 import { mayDirectCrew } from "../crew/direct.ts";
1212 import { timePhase } from "../core/route_timing.ts";
1313 import { getIssue } from "../core/issues.ts";
14 import { canRead, getRepoById } from "../core/repos.ts";
14 import { canRead, canWrite, getRepoById } from "../core/repos.ts";
1515 import { workingAgentRuns } from "../core/runs.ts";
1616 import { mayView, mayViewId } from "../core/sealed.ts";
1717 import { getUserById, isActive } from "../core/users.ts";
@@ -92,3 +92,26 @@ export function tellLink(
9292 if (!canRead(db, repo, viewer) || !mayDirectCrew(db, repo, viewer)) return undefined;
9393 return new URL(`/${repo.ownerName}/${repo.name}/tell`, forgeUrl).href;
9494 }
95
96 /**
97 * The forge's own "Make it yours" page for a public app, for a reader who is
98 * not on its crew: signed out they meet the page's sign-in, which returns to
99 * it. Nothing for a member of the repository, or when the app is not public.
100 * The viewer comes from the app's own grant, never from the client's word.
101 */
102 export function yoursLink(
103 db: Database,
104 forgeUrl: string,
105 app: AppRecord,
106 viewerId: string | undefined,
107 ): string | undefined {
108 if (app.access !== "public") return undefined;
109 const repo = getRepoById(db, app.repoId);
110 if (repo === undefined || repo.visibility !== "public") return undefined;
111 if (viewerId !== undefined) {
112 const viewer = getUserById(db, viewerId);

Pin with tests: a read-only member of a public repo is offered the link, an inactive or unknown viewer id is not.

113 if (viewer === undefined || !isActive(viewer)) return undefined;
114 if (canWrite(db, repo, viewer)) return undefined;
115 }
116 return new URL(`/${repo.ownerName}/${repo.name}/make-it-yours`, forgeUrl).href;
117 }
src/apps/proxy.ts +6 −1
@@ -51,7 +51,7 @@ import {
5151 type EnvName,
5252 } from "./data.ts";
5353 import type { AppsRuntime, EnvStatus } from "./runtime.ts";
54 import { crewNow, tellLink } from "./crew.ts";
54 import { crewNow, tellLink, yoursLink } from "./crew.ts";
5555 import { sideOf, type Side } from "./side.ts";
5656 import { feedback, mintProof } from "./feedback.ts";
5757 import { clientBucket } from "../http/peer.ts";
@@ -1038,6 +1038,10 @@ export async function proxyApp(
10381038 target.app,
10391039 signed?.who?.id,
10401040 );
1041 const yours =
1042 sealedPull(runtime, target) !== undefined
1043 ? undefined
1044 : yoursLink(runtime.app.db, runtime.options.forgeUrl, target.app, signed?.who?.id);
10411045 const side =
10421046 signed?.who === undefined
10431047 ? null
@@ -1046,6 +1050,7 @@ export async function proxyApp(
10461050 JSON.stringify({
10471051 ...crew,
10481052 ...(tell === undefined ? {} : { tell }),
1053 ...(yours === undefined ? {} : { yours }),
10491054 ...(side === null ? {} : { side }),
10501055 }),
10511056 );
src/apps/toolbar.ts +12
@@ -396,6 +396,18 @@ const toolbarSource = `(function () {
396396 crew.appendChild(ta);
397397 }
398398 }
399 // Make it yours: the forge's own fork page, on its origin; it asks a
400 // signed-out reader to sign in and returns to itself.
401 if (typeof c.yours === "string") {
402 var y = new URL(c.yours);
403 if (y.origin === forge) {
404 var ya = document.createElement("a");
405 ya.textContent = "Make it yours";

target=_blank takes a phone user out of the app to a new tab for what the issue calls one tap; prefer same-tab navigation.

406 ya.href = y.href;
407 ya.style.cssText = LS;
408 crew.appendChild(ya);
409 }
410 }
399411 // The other side: origin plus this path as text, so "//" cannot leave it.
400412 var sd = c.side;
401413 if (sd && typeof sd.label === "string" && typeof sd.url === "string") {
test/apps-crew-pill.test.ts +6 −4
@@ -156,7 +156,7 @@ describe("the crew line", () => {
156156 end(run.id);
157157 });
158158
159 test("/__forge/crew answers a granted viewer, and {} to no cookie or a bad one", async () => {
159 test("/__forge/crew answers a granted viewer, and only the fork link to no cookie or a bad one", async () => {
160160 const run = start(pullId);
161161 const target = { app: record, env: "production" } as AppTarget;
162162 const cookie = `forge_grant_${record.id.slice(-8)}`;
@@ -181,11 +181,13 @@ describe("the crew line", () => {
181181 url: `${servers.url}/alice/notes/runs/${run.id}`,
182182 tell: `${servers.url}/alice/notes/tell`,
183183 });
184 expect(await (await ask()).json()).toEqual({});
185 expect(await (await ask(`${grant}x`)).json()).toEqual({});
184 // A public app's signed-out reader is offered only the fork page.
185 const yours = { yours: `${servers.url}/alice/notes/make-it-yours` };
186 expect(await (await ask()).json()).toEqual(yours);
187 expect(await (await ask(`${grant}x`)).json()).toEqual(yours);
186188 // A grant that names nobody is a guest: nothing.
187189 const guest = signGrant(dataDir, target, 60_000);
188 expect(await (await ask(guest)).json()).toEqual({});
190 expect(await (await ask(guest)).json()).toEqual(yours);
189191 // A nested host is the guest forge's to answer, not this one's.
190192 const nested = await fetch(`${servers.url}/__forge/crew`, {
191193 headers: { host: `notes.other.${DOMAIN}`, cookie: `${cookie}=${grant}` },
test/apps-toolbar-yours.test.ts added +200
@@ -0,0 +1,200 @@
1 /** The pill's "Make it yours": a public app's stranger gets the fork page; the owner and a private app get nothing. */
2
3 import { afterAll, beforeAll, expect, spyOn, 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 { yoursLink } from "../src/apps/crew.ts";
10 import { signGrant, type AppTarget } from "../src/apps/proxy.ts";
11 import { AppsRuntime, type EnvStatus } from "../src/apps/runtime.ts";
12 import { toolbarJs } from "../src/apps/toolbar.ts";
13 import { getAppByRepo, type AppRecord } from "../src/core/apps.ts";
14 import { createIssue } from "../src/core/issues.ts";
15 import { addTeamMember, createOrg, createTeam } from "../src/core/orgs.ts";
16 import { setSealed } from "../src/core/sealed.ts";
17 import { createUser } from "../src/core/users.ts";
18 import { serve, type Servers } from "../src/http/server.ts";
19 import { quietLogs } from "./helpers/globals.ts";
20
21 quietLogs();
22
23 const DOMAIN = "apps.test";
24 let dataDir = "";
25 let app: App;
26 let servers: Servers;
27 let runtime: AppsRuntime;
28 let alice = "";
29 let bob = "";
30 let pub: AppRecord;
31 let priv: AppRecord;
32 let orgApp: AppRecord;
33 let reader = "";
34 let gone = "";
35 let openPull = 0;
36 let sealedPullNo = 0;
37 let backend: ReturnType<typeof Bun.serve>;
38 let status: ReturnType<typeof spyOn>;
39
40 beforeAll(async () => {
41 dataDir = await mkdtemp(join(tmpdir(), "forge-yours-"));
42 app = createApp({ dataDir, port: 0, host: "forge.test" });
43 servers = await serve(app);
44 runtime = new AppsRuntime(app, { domain: DOMAIN, scheme: "http", publicPort: servers.port, forgeUrl: servers.url, concurrency: 1 });
45 await runtime.start();
46 const person = async (username: string, adminIfFirst?: boolean) => {
47 const u = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", ...(adminIfFirst ? { adminIfFirst } : {}) });
48 if (!u.ok) throw new Error(u.error);
49 return u.value;
50 };
51 const a = await person("alice", true);
52 alice = a.id;
53 bob = (await person("bob")).id;
54 const make = async (name: string, visibility: "public" | "private") => {
55 const made = await newApp(app, a, { name, description: "x", visibility });
56 if (!made.ok) throw new Error(String(made.error));
57 return getAppByRepo(app.db, made.value.repo.id)!;
58 };
59 backend = Bun.serve({
60 port: 0,
61 fetch(request) {
62 const path = new URL(request.url).pathname;
63 if (path === "/data") return Response.json({ ok: 1 });
64 if (path === "/asset.js") return new Response("console.log(1)", { headers: { "content-type": "application/javascript" } });
65 return new Response("<html><body>hi</body></html>", { headers: { "content-type": "text/html; charset=utf-8" } });
66 },
67 });
68 status = spyOn(AppsRuntime.prototype, "envStatus").mockImplementation(
69 (_r: AppRecord, env: string) => ({ env, state: "running", port: backend.port, commitOid: "a".repeat(40) }) as EnvStatus,
70 );
71 reader = (await person("reader")).id;
72 gone = (await person("gone")).id;
73 app.db.query("UPDATE user SET suspended_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?1").run(gone);
74 const org = createOrg(app.db, "acme", alice);
75 if (!org.ok) throw new Error(org.error);
76 const team = createTeam(app.db, org.value.id, "Readers", "read");
77 if (!team.ok) throw new Error(team.error);
78 addTeamMember(app.db, team.value, reader);
79 const madeOrg = await newApp(app, a, { name: "orgapp", owner: "acme", description: "x", visibility: "public" });
80 if (!madeOrg.ok) throw new Error(String(madeOrg.error));
81 orgApp = getAppByRepo(app.db, madeOrg.value.repo.id)!;
82 pub = await make("pubapp", "public");
83 for (const sealed of [false, true]) {
84 const pull = createIssue(app.db, { repoId: pub.repoId, authorId: a.id, title: "p", kind: "pull" });
85 if (!pull.ok) throw new Error(pull.error);
86 if (sealed) {
87 setSealed(app.db, pull.value.id, true);
88 sealedPullNo = pull.value.number;
outdated

Substring checks on the source prove nothing about rendering. Run the pill against a crew answer with yours and assert the anchor, its href, and that a foreign-origin yours renders nothing. Also add JSON/asset-response and sealed-pull cases.

89 } else openPull = pull.value.number;
90 }
91 priv = await make("privapp", "private");
92 });
93 afterAll(async () => {
94 status.mockRestore();
95 backend.stop(true);
96 await runtime.stop();
97 servers.stop();
98 app.close();
99 await rm(dataDir, { recursive: true, force: true });
100 });
101
102 const link = (a: AppRecord, who?: string) => yoursLink(app.db, servers.url, a, who);
103
104 test("a signed-out reader and a signed-in stranger of a public app are offered the fork page", () => {
105 const want = `${servers.url}/alice/pubapp/make-it-yours`;
106 expect(link(pub)).toBe(want);
107 expect(link(pub, bob)).toBe(want);
108 });
109
110 test("the owner is offered nothing", () => {
111 expect(link(pub, alice)).toBeUndefined();
112 });
113
114 test("a private app is offered nothing", () => {
115 expect(priv.access).toBe("private");
116 expect(link(priv)).toBeUndefined();
117 expect(link(priv, bob)).toBeUndefined();
118 });
119
120 test("the endpoint takes who the reader is from the grant, never the client", async () => {
121 const target = { app: pub, env: "production" } as AppTarget;
122 const cookie = `forge_grant_${pub.id.slice(-8)}`;
123 const ask = async (who: { id: string; name: string; role: string }) =>
124 (await fetch(`${servers.url}/__forge/crew`, {
125 headers: { host: `pubapp.${DOMAIN}`, cookie: `${cookie}=${signGrant(dataDir, target, 60_000, Date.now(), who as never)}` },
126 })).json() as Promise<{ yours?: string }>;
127 expect((await ask({ id: bob, name: "bob", role: "user" })).yours).toBe(`${servers.url}/alice/pubapp/make-it-yours`);
128 expect((await ask({ id: alice, name: "alice", role: "admin" })).yours).toBeUndefined();
129 });
130
131 test("a read-only member of a public repository is offered the link; a suspended or unknown viewer is not", () => {
132 const want = `${servers.url}/acme/orgapp/make-it-yours`;
133 expect(link(orgApp, reader)).toBe(want);
134 expect(link(orgApp, alice)).toBeUndefined();
135 expect(link(pub, gone)).toBeUndefined();
136 expect(link(pub, "no-such-user")).toBeUndefined();
137 // A signed-out reader is the one who is offered it with no account at all.
138 expect(link(pub)).toBeDefined();
139 });
140
141 const crewAt = async (host: string, who?: string, env = "production") => {
142 const target = { app: pub, env } as AppTarget;
143 const cookie = who === undefined ? {} : { cookie: `forge_grant_${pub.id.slice(-8)}=${signGrant(dataDir, target, 60_000, Date.now(), { id: who, name: "x", role: "user" } as never)}` };
144 return (await fetch(`${servers.url}/__forge/crew`, { headers: { host, ...cookie } })).json() as Promise<{ yours?: string }>;
145 };
146
147 test("through the proxy, a preview of a sealed pull offers no link; an open pull's does", async () => {
148 expect((await crewAt(`pubapp--${openPull}.${DOMAIN}`, bob, `pull-${openPull}`)).yours).toBe(`${servers.url}/alice/pubapp/make-it-yours`);
149 expect((await crewAt(`pubapp--${sealedPullNo}.${DOMAIN}`, bob, `pull-${sealedPullNo}`)).yours).toBeUndefined();
150 expect((await crewAt(`pubapp--${sealedPullNo}.${DOMAIN}`)).yours).toBeUndefined();
151 expect((await crewAt(`pubapp.${DOMAIN}`, gone)).yours).toBeUndefined();
152 });
153
154 test("through the proxy, the link is in no JSON or asset answer; a page carries only the pill's tag", async () => {
155 const get = (path: string) => fetch(`${servers.url}${path}`, { headers: { host: `pubapp.${DOMAIN}` } });
156 const json = await get("/data");
157 expect(await json.text()).toBe('{"ok":1}');
158 const asset = await get("/asset.js");
159 expect(await asset.text()).toBe("console.log(1)");
160 for (const res of [json, asset]) expect(res.headers.get("link") ?? "").not.toContain("make-it-yours");
161 const page = await (await get("/")).text();
162 expect(page).toContain("/__forge/toolbar-");
163 expect(page).not.toContain("make-it-yours");
164 });
165
166 /** Run the pill against a stub DOM and a crew answer; return the anchors and what sits under the shadow root. */
167 async function runPill(crew: unknown) {
168 const forge = "http://forge.test";
169 const anchors: Record<string, any>[] = [];
170 const el = (): Record<string, any> => ({
171 style: {}, dataset: {}, children: [] as any[],
172 appendChild(c: any) { this.children.push(c); return c; },
173 setAttribute() {}, addEventListener() {}, focus() {},
174 attachShadow() { shadow = el(); return shadow; },
175 });
176 let shadow: Record<string, any> | undefined;
177 const make = (tag: string) => { const e = el(); e.tag = tag; if (tag === "a") anchors.push(e); return e; };
178 const inShadow = (node: any, hit: any): boolean => node === hit || (node.children ?? []).some((c: any) => inShadow(c, hit));
179 const doc = { currentScript: { src: "/__forge/toolbar-x.js", dataset: { forge, app: "pubapp", env: "production" } }, createElement: make, addEventListener() {}, body: el() };
180 const fn = new Function("window", "document", "fetch", "location", "URL", toolbarJs);
181 await new Promise<void>((done) => {
182 fn({}, doc, () => ({
183 then: (f: (r: unknown) => unknown) => {
184 f({ ok: true, json: () => crew });
185 return { then: (g: (c: unknown) => void) => { g(crew); setTimeout(done, 0); return { catch() {} }; } };
186 },
187 }), { href: "https://pubapp.apps.test/" }, URL);
188 });
189 const yours = anchors.find((a) => a.textContent === "Make it yours");
190 return { yours, inShadow: yours !== undefined && shadow !== undefined && inShadow(shadow, yours) };
191 }
192
193 test("the pill makes the anchor in its shadow root, same tab, with the forge's href; a foreign origin makes none", async () => {
194 const good = await runPill({ yours: "http://forge.test/alice/pubapp/make-it-yours" });
195 expect(good.yours?.href).toBe("http://forge.test/alice/pubapp/make-it-yours");
196 expect(good.inShadow).toBe(true);
197 expect(good.yours?.target).toBeUndefined();
198 expect((await runPill({ yours: "https://evil.test/alice/pubapp/make-it-yours" })).yours).toBeUndefined();
199 expect((await runPill({})).yours).toBeUndefined();
200 });
test/never-lost.test.ts +4 −1
@@ -153,7 +153,10 @@ describe("never lost: the pill from production and from a preview", () => {
153153 expect(html).toContain(' data-app="notes"');
154154 expect(html).toContain(` data-env="${env()}"`);
155155 if (place.preview) expect(html).toContain(` data-pull="${pull}"`);
156 expect(await (await ask(host(), "/__forge/crew")).json()).toEqual({});
156 // A public app offers a stranger Make it yours, never the tell page.
157 expect(await (await ask(host(), "/__forge/crew")).json()).toEqual({
158 yours: `${servers.url}/alice/notes/make-it-yours`,
159 });
157160 const door = await (await ask(host(), "/__forge/")).text();
158161 expect(door).toContain("/__forge/signin");
159162 expect(door).not.toContain("/alice/notes/tell");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs