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
Check · Review · Land · Live — Pushed since the last review.
scout/forge:scout/11463-the-toolbar-on-a-public-app-that-is-not → main 3 commits fork 3 threads · 3 unresolved
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. | ||
| 9 | 9 | byte. A page that already carries the tag is not given a second. |
| 10 | 10 | - **What it says:** the app, the environment and, on a preview, a link |
| 11 | 11 | "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. | |
| 12 | 22 | - **What a note carries:** a note sent from the pill also carries where it came |
| 13 | 23 | from, folded at the foot of the issue under "Where this came from": the |
| 14 | 24 | 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. | ||
| 29 | 39 | pill out; the app still works. |
| 30 | 40 | - **Off:** `"toolbar": false` in `forge.json`. |
| 31 | 41 | |
| 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"; | ||
| 11 | 11 | import { mayDirectCrew } from "../crew/direct.ts"; |
| 12 | 12 | import { timePhase } from "../core/route_timing.ts"; |
| 13 | 13 | import { getIssue } from "../core/issues.ts"; |
| 14 | import { canRead, getRepoById } from "../core/repos.ts"; | |
| 14 | import { canRead, canWrite, getRepoById } from "../core/repos.ts"; | |
| 15 | 15 | import { workingAgentRuns } from "../core/runs.ts"; |
| 16 | 16 | import { mayView, mayViewId } from "../core/sealed.ts"; |
| 17 | 17 | import { getUserById, isActive } from "../core/users.ts"; |
| @@ -92,3 +92,26 @@ export function tellLink( | ||
| 92 | 92 | if (!canRead(db, repo, viewer) || !mayDirectCrew(db, repo, viewer)) return undefined; |
| 93 | 93 | return new URL(`/${repo.ownerName}/${repo.name}/tell`, forgeUrl).href; |
| 94 | 94 | } |
| 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); | |
| 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 { | ||
| 51 | 51 | type EnvName, |
| 52 | 52 | } from "./data.ts"; |
| 53 | 53 | import type { AppsRuntime, EnvStatus } from "./runtime.ts"; |
| 54 | import { crewNow, tellLink } from "./crew.ts"; | |
| 54 | import { crewNow, tellLink, yoursLink } from "./crew.ts"; | |
| 55 | 55 | import { sideOf, type Side } from "./side.ts"; |
| 56 | 56 | import { feedback, mintProof } from "./feedback.ts"; |
| 57 | 57 | import { clientBucket } from "../http/peer.ts"; |
| @@ -1038,6 +1038,10 @@ export async function proxyApp( | ||
| 1038 | 1038 | target.app, |
| 1039 | 1039 | signed?.who?.id, |
| 1040 | 1040 | ); |
| 1041 | const yours = | |
| 1042 | sealedPull(runtime, target) !== undefined | |
| 1043 | ? undefined | |
| 1044 | : yoursLink(runtime.app.db, runtime.options.forgeUrl, target.app, signed?.who?.id); | |
| 1041 | 1045 | const side = |
| 1042 | 1046 | signed?.who === undefined |
| 1043 | 1047 | ? null |
| @@ -1046,6 +1050,7 @@ export async function proxyApp( | ||
| 1046 | 1050 | JSON.stringify({ |
| 1047 | 1051 | ...crew, |
| 1048 | 1052 | ...(tell === undefined ? {} : { tell }), |
| 1053 | ...(yours === undefined ? {} : { yours }), | |
| 1049 | 1054 | ...(side === null ? {} : { side }), |
| 1050 | 1055 | }), |
| 1051 | 1056 | ); |
src/apps/toolbar.ts +12
| @@ -396,6 +396,18 @@ const toolbarSource = `(function () { | ||
| 396 | 396 | crew.appendChild(ta); |
| 397 | 397 | } |
| 398 | 398 | } |
| 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 | } | |
| 399 | 411 | // The other side: origin plus this path as text, so "//" cannot leave it. |
| 400 | 412 | var sd = c.side; |
| 401 | 413 | 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", () => { | ||
| 156 | 156 | end(run.id); |
| 157 | 157 | }); |
| 158 | 158 | |
| 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 () => { | |
| 160 | 160 | const run = start(pullId); |
| 161 | 161 | const target = { app: record, env: "production" } as AppTarget; |
| 162 | 162 | const cookie = `forge_grant_${record.id.slice(-8)}`; |
| @@ -181,11 +181,13 @@ describe("the crew line", () => { | ||
| 181 | 181 | url: `${servers.url}/alice/notes/runs/${run.id}`, |
| 182 | 182 | tell: `${servers.url}/alice/notes/tell`, |
| 183 | 183 | }); |
| 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); | |
| 186 | 188 | // A grant that names nobody is a guest: nothing. |
| 187 | 189 | const guest = signGrant(dataDir, target, 60_000); |
| 188 | expect(await (await ask(guest)).json()).toEqual({}); | |
| 190 | expect(await (await ask(guest)).json()).toEqual(yours); | |
| 189 | 191 | // A nested host is the guest forge's to answer, not this one's. |
| 190 | 192 | const nested = await fetch(`${servers.url}/__forge/crew`, { |
| 191 | 193 | 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 |
||
| 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", () => { | ||
| 153 | 153 | expect(html).toContain(' data-app="notes"'); |
| 154 | 154 | expect(html).toContain(` data-env="${env()}"`); |
| 155 | 155 | 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 | }); | |
| 157 | 160 | const door = await (await ask(host(), "/__forge/")).text(); |
| 158 | 161 | expect(door).toContain("/__forge/signin"); |
| 159 | 162 | expect(door).not.toContain("/alice/notes/tell"); |
Pin with tests: a read-only member of a public repo is offered the link, an inactive or unknown viewer id is not.