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

Every repository and app page says who can see it in a sentence that names the people, and the API and MCP say the same #2555

Merged opened this

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

Merged

scout/forge:scout/809-every-repository-and-app-page-says-who-c → main 8 commits fork 7 threads · 7 unresolved

  • Merged. scout/809-every-repository-and-app-page-says-who-c was merged into main as 2bd59fc.

Review

Sign in to review this change.
4 files changed +166 −0
README.md +9
@@ -379,6 +379,15 @@ terminal's **Chain** command; the owner severs it with the page's Sever,
379379 updates into a conflict opens the converge issue at every door. See
380380 `docs/SPAWN.md` and `docs/FOLLOWING.md`.
381381
382 ## Who can see it
383
384 Every repository page says, beside the Private/Public badge, who can see it in
385 one sentence ("Private: only you and the crew."), and a writer's click goes to
386 Settings. `GET /api/v1/repos/:owner/:name` returns the same words as
387 `audience: { level, sentence }`, the MCP `repo` tool carries it too, and the
388 terminal's About card (`I`) shows it. It gives counts, never names; site
389 admins are not counted. All of them read `audienceFor` in `src/core/audience.ts`.
390
382391 ## Archive
383392
384393 An admin puts a repository away from **Settings → Archive**, with
src/tui/screens/about.ts +13
@@ -15,6 +15,7 @@
1515 * card is an OSC 8 hyperlink besides.
1616 */
1717
18 import { audienceFor } from "../../core/audience.ts";
1819 import { isPinned } from "../../core/pins.ts";
1920 import { parentReleases, releasesSentence } from "../../core/parent_releases.ts";
2021 import { running } from "../../kernel/version.ts";
@@ -128,6 +129,18 @@ export function aboutScreen(
128129 style(description === "" ? c.faint : c.fg, c.card),
129130 edge,
130131 );
132 // Who can see it, in the web page's words (#809).
133 if (repo !== undefined)
134 s.text(
135 lead,
136 inner.y + 2,
137 truncate(
138 audienceFor(f.db, repo, f.user).sentence,

Low (cost): audienceFor runs audienceFacts on every render, and that does one REPO_VISIBLE/REPO_WRITABLE query per candidate. The card re-renders on every invalidate. Work it out once when the card opens, as rows is, and render the stored string. The layout comment at line 78 ("the description, a blank") is now out of date too: that row holds the sentence.

139 Math.max(0, edge - lead),
140 ),
141 style(c.mutedFg, c.card),

Non-blocking: truncate cuts the long split-level sentence (public code, private app) mid-phrase on a narrow terminal. Wrap onto a second row when the card has room, so the people named are not lost.

142 edge,
143 );
131144 drawFacts(
132145 s,
133146 {
test/audience-surfaces.test.ts added +119
@@ -0,0 +1,119 @@
1 /**
2 * The page, the API and the MCP say the same sentence about who can see a
3 * repository (#809); the wording itself is tested in audience.test.ts.
4 */
5 import { proveAll } from "./helpers/verified.ts";
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createRepo } from "../src/actions/repos.ts";
11 import { createApp, type App } from "../src/app.ts";
12 import { audienceFor } from "../src/core/audience.ts";
13 import { createApp as makeApp } from "../src/core/apps.ts";
14 import { createToken } from "../src/core/tokens.ts";
15 import { createUser, type User } from "../src/core/users.ts";
16 import { serve, type Servers } from "../src/http/server.ts";
17
18 const esc = (s: string): string =>
19 s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
20
21 /** The audience element of a page: its tag, link and text. */
22 function audienceOn(page: string) {
23 const m = /<(a|span) class="repo-audience"(?: href="([^"]*)")? title="([^"]*)"\s*>([^<]*)<\/\1\s*>/.exec(page);
24 if (m === null) throw new Error("no audience element on the page");
25 return { tag: m[1], href: m[2], title: m[3], text: m[4] };
26 }
27
28 describe("one sentence on every surface", () => {
29 let dataDir: string;
30 let app: App;
31 let servers: Servers;
32 let olive: User;
33
34 beforeAll(async () => {
35 dataDir = await mkdtemp(join(tmpdir(), "forge-audience-"));
36 app = createApp({ dataDir, port: 0 });
37 servers = await serve(app);
38 const made = await createUser(app.db, {
39 origin: "seed",
40 username: "olive",
41 email: "[email protected]",
42 password: "a-good-passphrase",
43 isAdmin: true,
44 });
45 if (!made.ok) throw new Error(made.error);
46 olive = made.value;
47 proveAll(app);
48 });
49 afterAll(async () => {
50 servers.stop();
51 app.close();
52 await rm(dataDir, { recursive: true, force: true });
53 });
54
55 test("the API and the MCP say the same words, and the page shows them", async () => {
56 const made = await createRepo(app, olive, { name: "garden", visibility: "public" });
57 if (!made.ok) throw new Error(String(made.error));
58 makeApp(app.db, { repoId: made.value.id, slug: "garden", access: "private" });
59 const token = createToken(app.db, olive.id, "t", "ro").token;
60 const auth = { authorization: `Bearer ${token}` };
61 const res = await fetch(`${servers.url}/api/v1/repos/olive/garden`, { headers: auth });
62 const body = (await res.json()) as { audience: { level: string; sentence: string } };
63 expect(body.audience).toEqual(audienceFor(app.db, made.value, olive));
64 expect(body.audience.sentence).toStartWith("The code is public; the app answers only to");
65
66 const mcp = await fetch(`${servers.url}/mcp`, {
67 method: "POST",
68 headers: {
69 ...auth,
70 "content-type": "application/json",
71 accept: "application/json, text/event-stream",
72 },
73 body: JSON.stringify({
outdated

Checks only the class. Assert the sentence, HTML-escaped, so the page is proved to say what the API says, and add the signed-out case and the non-admin <span> case.

74 jsonrpc: "2.0",
75 id: 1,
76 method: "tools/call",
77 params: { name: "repo", arguments: { repo: "olive/garden" } },
78 }),
79 });
80 expect(await mcp.text()).toContain("The code is public; the app answers only to");
81
82 const page = await (await fetch(`${servers.url}/olive/garden`, { headers: auth })).text();
83 const said = esc(body.audience.sentence);
84 expect(audienceOn(page)).toEqual({
85 tag: "a",
86 href: "/olive/garden/settings",
87 title: said,
88 text: said,
89 });
90 });
91
92 test("signed out, the page and the API say the same; a non-admin gets no link", async () => {
93 const made = await createRepo(app, olive, { name: "meadow", visibility: "public" });
94 if (!made.ok) throw new Error(String(made.error));
95 const api = (await (await fetch(`${servers.url}/api/v1/repos/olive/meadow`)).json()) as {
96 audience: { sentence: string };
97 };
98 expect(api.audience.sentence).toStartWith("Public: anyone can read this");
99 const said = esc(api.audience.sentence);
100 const out = await (await fetch(`${servers.url}/olive/meadow`)).text();
101 expect(audienceOn(out)).toEqual({ tag: "span", href: undefined, title: said, text: said });
102
103 const wren = await createUser(app.db, {
104 origin: "seed",
105 username: "wren",
106 email: "[email protected]",
107 password: "a-good-passphrase",
108 });
109 if (!wren.ok) throw new Error(wren.error);
110 proveAll(app);
111 const token = createToken(app.db, wren.value.id, "t", "ro").token;
112 const seen = await (
113 await fetch(`${servers.url}/olive/meadow`, {
114 headers: { authorization: `Bearer ${token}` },
115 })
116 ).text();
117 expect(audienceOn(seen)).toEqual({ tag: "span", href: undefined, title: said, text: said });
118 });
119 });
test/tui-workspace.test.ts +25
@@ -20,6 +20,7 @@ import { tmpdir } from "node:os";
2020 import { join } from "node:path";
2121 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
2222 import { createApp, type App as Forge } from "../src/app.ts";
23 import { audienceFor } from "../src/core/audience.ts";
2324 import { getRepo, type Repo } from "../src/core/repos.ts";
2425 import { listPulls } from "../src/core/pulls.ts";
2526 import type { User } from "../src/core/users.ts";
@@ -578,6 +579,30 @@ describe("the terminal workspace", () => {
578579 }
579580 });
580581
582 test("the about card says who can see the repository, as the API does", async () => {
583 const guest = boot({
584 sshUsername: "git",
585 remoteAddress: "127.0.0.1",
586 fingerprint: "SHA256:unknown",
587 });
588 const member = boot();
589 try {
590 for (const [h, viewer] of [
591 [member, alice],
592 [guest, undefined],
593 ] as const) {
594 await h.settle();
595 await open(h);
596 await h.press("I");
597 await h.settle();
598 expect(h.text()).toContain(audienceFor(forge.db, repo, viewer).sentence);
599 }
600 } finally {
601 member.stop();
602 guest.stop();
603 }
604 });
605
581606 // ---- read-only ----
582607
583608 test("a reader who cannot write is told so rather than shown an editor", async () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs