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

Private by default is plain: every repository and app page says in one line who can see it, and the API gives the same line #10394

Merged opened this

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

Merged

mason/forge:mason/10391-private-by-default-is-plain-every-reposi → main 3 commits fork 1 thread · 1 unresolved

  • Merged. mason/10391-private-by-default-is-plain-every-reposi was merged into main as 39198ef.

Review

Sign in to review this change.
9 files changed +306 −8
docs/TOUR.md +5 −2
@@ -287,8 +287,11 @@ never, a private app never. Every door reads `frontJson` in
287287 Every repository page says, beside the Private/Public badge, who can see it in
288288 one sentence ("Private: only you and the crew."), and a writer's click goes to
289289 Settings. `GET /api/v1/repos/:owner/:name` returns the same words as
290 `audience: { level, sentence }`, the MCP `repo` tool carries it too, and the
291 terminal's About card (`I`) shows it. It gives counts, never names; site
290 `audience: { level, who, sentence }` (`who` is `owner`, `members` or
291 `anyone`), and so does a `PATCH` that changes its visibility, in the same
292 answer. The MCP `repo` and `here` tools carry it too (`here` on a repository
293 page, and on an app's link only for someone who may read the repository), and
294 the terminal's About card (`I`) shows the sentence (`docs/features/visibility-line.md`). It gives counts, never names; site
292295 admins are not counted. All of them read `audienceFor` in `src/core/audience.ts`.
293296
294297 ## Archive
docs/features/visibility-line.md added +32
@@ -0,0 +1,32 @@
1 # Who can see it, in one line
2
3 Every repository starts private: a new repository, a new app and its
4 repository, a fork of a private repository (whatever the fork asks for) and the
5 preview of a private app's pull. Each surface then says who can see it in the
6 same words, all read from `audienceFor` in `src/core/audience.ts`:
7
8 ```json
9 "audience": { "level": "private", "who": "owner", "sentence": "Private: only you and the crew." }
10 ```
11
12 - **`who`** is one word a client can branch on: `owner` (a person's private
13 repository: its owner and their crew), `members` (an organization's private
14 repository) or `anyone` (public). The `sentence` says the same for a person.
15 It is named `audience`, not `visibility`, because `visibility` is already the
16 `"public" | "private"` string clients read.
17 - **Web**: the repository head shows the sentence beside the Private/Public badge.
18 - **API**: `GET /api/v1/repos/:owner/:name` carries it, and a `PATCH` that
19 changes `visibility` returns the new line in the same answer (the change is
20 receipted as `repo.settings_updated`).
21 - **MCP**: the `repo` tool carries it; `here` carries it on a repository page
22 and on an app's link. On an app's link it is there only for someone who may
23 read the repository: a public app can sit on a private repository, and its
24 visitors are told nothing about that repository, nor its counts.
25 - **Terminal**: the About card (`I`) prints the sentence. `who` is not printed
26 on its own there: the sentence already opens with it, and the terminal is
27 read by people, not branched on.
28
29 A stranger asking about a private repository gets exactly the answer a missing
30 one gives, on the API and through `here`.
31
32 Tried by `test/visibility-line.test.ts`.
src/actions/here.ts +4
@@ -12,6 +12,7 @@
1212 * An action that needs an account is listed for someone without one,
1313 * saying so, because that is the next thing they need to know.
1414 */
15 import { audienceFor } from "../core/audience.ts";
1516 import { lacks } from "../core/roles.ts";
1617 import { activeEdgeToken } from "../http/edge.ts";
1718 import { sshTellCommand } from "../ssh/command.ts";
@@ -65,6 +66,8 @@ export interface Manifest {
6566 readonly kind: "repository";
6667 readonly name: string;
6768 readonly visibility: "public" | "private";
69 /** Who can see it, in the words its page and `GET` say (`core/audience.ts`). */
70 readonly audience: ReturnType<typeof audienceFor>;
6871 readonly app?: {
6972 readonly access: "public" | "private";
7073 readonly state?: string;
@@ -242,6 +245,7 @@ function repoHere(
242245 kind: "repository" as const,
243246 name: `${repo.ownerName}/${repo.name}`,
244247 visibility: repo.visibility,
248 audience: audienceFor(app.db, repo, viewer),
245249 };
246250 // A private app the viewer may not enter is not there, to them.
247251 if (record === undefined || !canEnterApp(app.db, repo, record, viewer))
src/apps/here.ts +9 −1
@@ -4,7 +4,8 @@
44 * render from, as plain data. Anyone the door would turn away is refused.
55 */
66
7 import { getRepoById, canEnterApp } from "../core/repos.ts";
7 import { audienceFor } from "../core/audience.ts";
8 import { getRepoById, canEnterApp, canRead } from "../core/repos.ts";
89 import { mayView } from "../core/sealed.ts";
910 import { getIssue } from "../core/issues.ts";
1011 import type { User } from "../core/users.ts";
@@ -15,6 +16,12 @@ import { isHostPort, pillWhere, reportUrl, resolveAppHostname } from "./proxy.ts
1516 export type Here = {
1617 app: string;
1718 repo: string | null;
19 /**
20 * Who can see the repository, as its page says it (`core/audience.ts`).
21 * Only for someone who may read the repository: a public app can sit on a
22 * private one, and its visitors are not told so, nor its counts.
23 */
24 audience?: ReturnType<typeof audienceFor>;
1825 environment: string;
1926 pull: { number: number; title: string } | null;
2027 working: string[];
@@ -49,6 +56,7 @@ export function hereOf(runtime: AppsRuntime, link: string, user: User): Here | u
4956 return {
5057 app: where.app,
5158 repo: where.repo ?? null,
59 ...(canRead(app.db, repo, user) ? { audience: audienceFor(app.db, repo, user) } : {}),
5260 environment: target.env,
5361 pull:
5462 where.pull !== undefined && where.title !== undefined
src/core/audience.ts +15 −2
@@ -46,6 +46,18 @@ export interface Audience {
4646 readonly sentence: string;
4747 }
4848
49 /**
50 * Who may read the repository, as one word a client can branch on: its
51 * owner (and the owner's crew), the members of the organization that owns
52 * it, or anyone. The sentence says the same thing for a person to read.
53 */
54 export type Who = "owner" | "members" | "anyone";
55
56 export function whoOf(facts: Pick<AudienceFacts, "visibility" | "ownerKind">): Who {
57 if (facts.visibility === "public") return "anyone";
58 return facts.ownerKind === "org" ? "members" : "owner";
59 }
60
4961 const people = (n: number): string => (n === 1 ? "1 person" : `${n} people`);
5062
5163 /** "a", "a and b", "a, b, and c". */
@@ -218,6 +230,7 @@ export function audienceFor(
218230 db: Database,
219231 repo: Repo,
220232 viewer: User | undefined,
221 ): Audience {
222 return audienceOf(audienceFacts(db, repo, viewer));
233 ): Audience & { readonly who: Who } {
234 const facts = audienceFacts(db, repo, viewer);
235 return { ...audienceOf(facts), who: whoOf(facts) };
223236 }
src/http/api.ts +5 −1
@@ -1665,7 +1665,11 @@ export function apiRoutes(router: Router<Handler>): void {
16651665 const shown = showProvenance(ctx.app, ctx.auth.user, repo, input.showProvenance);
16661666 if (!shown.ok) return respondAccess(shown.error, 422);
16671667 }
1668 return json(repoJson(ctx, updated.value));
1668 // The line about who can see it moves with the setting, in this answer.
1669 return json({
1670 ...repoJson(ctx, updated.value),
1671 audience: audienceFor(ctx.app.db, updated.value, ctx.auth.user),
1672 });
16691673 });
16701674
16711675 router.add("DELETE", "/api/v1/repos/:owner/:name", async (ctx) => {
src/http/mcp/tools/here.ts +1 −1
@@ -7,7 +7,7 @@ export const tool: Tool = {
77 name: "here",
88 description:
99 "What you may do on a forge page; given an app's link, what its /__forge/ door shows.",
10 doc: "`{ url }`: on a forge page, what you may do there as who you are, each action with its route and tool; on the link of one of this forge's app hosts, what its `/__forge/` door shows: `{ app, repo, environment, pull: { number, title } or null, working: [names], links: { home, repo, report } }`; a foreign host, or a private app you may not enter, is refused like a missing page",
10 doc: "`{ url }`: on a forge page, what you may do there as who you are, each action with its route and tool; on the link of one of this forge's app hosts, what its `/__forge/` door shows: `{ app, repo, audience: { level, who, sentence } (only when you may read the repository), environment, pull: { number, title } or null, working: [names], links: { home, repo, report } }`; a foreign host, or a private app you may not enter, is refused like a missing page",
1111 inputSchema: {
1212 type: "object",
1313 properties: { url: { type: "string", maxLength: 2000 } },
src/http/mcp/tools/repo.ts +1 −1
@@ -6,7 +6,7 @@ import { good, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
66
77 export const tool: Tool = {
88 name: "repo",
9 doc: "`{ repo }`: one repository's description, visibility, autonomy, `audience { level, sentence }` and `provenance` (where a migrated one came from, `null` when hidden), the same as `GET /api/v1/repos/:owner/:name`",
9 doc: "`{ repo }`: one repository's description, visibility, autonomy, `audience { level, who, sentence }` and `provenance` (where a migrated one came from, `null` when hidden), the same as `GET /api/v1/repos/:owner/:name`",
1010 description:
1111 "One repository you can read, as GET /api/v1/repos/:owner/:name.",
1212 inputSchema: {
test/visibility-line.test.ts added +234
@@ -0,0 +1,234 @@
1 /**
2 * Private by default, and plain about it (#10391): a new repository, a new
3 * app, a fork of a private one and the preview of a private app's pull each
4 * start private, and the API, the MCP `here` tool and a visibility change all
5 * say so in the same words (`audience { level, who, sentence }`). A stranger
6 * asking about a private repository gets exactly what a missing one gets.
7 */
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { createApp, type App } from "../src/app.ts";
13 import { AppsRuntime } from "../src/apps/runtime.ts";
14 import { newApp } from "../src/actions/apps.ts";
15 import { openPull } from "../src/actions/pulls.ts";
16 import { commitToBranch, createRepo, forkRepo } from "../src/actions/repos.ts";
17 import { audienceFor, whoOf } from "../src/core/audience.ts";
18 import { createApp as createAppRecord, getAppByRepo } from "../src/core/apps.ts";
19 import { getRepo } from "../src/core/repos.ts";
20 import { createUser, getUserByName, type User } from "../src/core/users.ts";
21 import { serve, type Servers } from "../src/http/server.ts";
22 import { seedAdmin } from "./helpers/admin.ts";
23 import { promote } from "./helpers/members.ts";
24 import { tokenFor } from "./helpers/tokens.ts";
25 import { quietLogs } from "./helpers/globals.ts";
26
27 quietLogs();
28
29 describe("who can see it, said once, everywhere", () => {
30 let dataDir: string;
31 let app: App;
32 let servers: Servers;
33 let runtime: AppsRuntime;
34 let alice: User;
35 let aliceRw = "";
36 let bobRw = "";
37 let pull = 0;
38 let id = 1;
39
40 const here = async (token: string, url: string) => {
41 const res = await fetch(`${servers.url}/mcp`, {
42 method: "POST",
43 headers: {
44 "content-type": "application/json",
45 accept: "application/json, text/event-stream",
46 authorization: `Bearer ${token}`,
47 },
48 body: JSON.stringify({
49 jsonrpc: "2.0",
50 id: id++,
51 method: "tools/call",
52 params: { name: "here", arguments: { url } },
53 }),
54 });
55 const body: any = await res.json();
56 const text: string = body.result.content[0].text;
57 return body.result.isError === true ? { error: text } : { value: JSON.parse(text) };
58 };
59 const api = (path: string, token: string, init: RequestInit = {}) =>
60 fetch(`${servers.url}/api/v1${path}`, {
61 ...init,
62 headers: {
63 ...(token ? { authorization: `Bearer ${token}` } : {}),
64 "content-type": "application/json",
65 },
66 });
67 const appHost = (label: string) => `http://${label}.apps.test:${servers.port}/`;
68
69 beforeAll(async () => {
70 dataDir = await mkdtemp(join(tmpdir(), "forge-visibility-line-"));
71 app = createApp({ dataDir, port: 0 });
72 servers = await serve(app);
73 runtime = new AppsRuntime(app, {
74 domain: "apps.test",
75 scheme: "http",
76 publicPort: servers.port,
77 forgeUrl: servers.url,
78 });
79 await runtime.start();
80 await seedAdmin(app, { username: "root", email: "[email protected]", password: "a-good-passphrase" });
81 for (const name of ["alice", "bob"]) {
82 const made = await createUser(app.db, {
83 origin: "seed",
84 trusted: true,
85 username: name,
86 email: `${name}@forge.local`,
87 password: "a-good-passphrase",
88 });
89 if (!made.ok) throw new Error(made.error);
90 promote(app.db, made.value);
91 }
92 alice = getUserByName(app.db, "alice")!;
93 aliceRw = tokenFor(app, "alice", "admin");
94 bobRw = tokenFor(app, "bob", "rw");
95 }, 30_000);
96
97 afterAll(async () => {
98 await runtime.stop();
99 servers.stop();
100 app.close();
101 await rm(dataDir, { recursive: true, force: true });
102 });
103
104 test("a new repository is private to its owner, and GET says so", async () => {
105 const made = await createRepo(app, alice, { name: "notes" });
106 if (!made.ok) throw new Error(String(made.error));
107 expect(made.value.visibility).toBe("private");
108 const body: any = await (await api("/repos/alice/notes", aliceRw)).json();
109 expect(body.audience.who).toBe("owner");
110 expect(body.audience.level).toBe("private");
111 expect(body.audience).toEqual(audienceFor(app.db, made.value, alice));
112 });
113
114 test("a stranger asking about a private repository gets what a missing one gets", async () => {
115 for (const token of [bobRw, ""]) {
116 const hidden = await api("/repos/alice/notes", token);
117 const missing = await api("/repos/alice/no-such-thing", token);
118 expect(hidden.status).toBe(missing.status);
119 expect(await hidden.text()).toBe(await missing.text());
120 }
121 const hidden = await here(bobRw, `${servers.url}/alice/notes`);
122 const missing = await here(bobRw, `${servers.url}/alice/no-such-thing`);
123 expect(hidden.error).toBeDefined();
124 expect(hidden.error).toBe(missing.error);
125 });
126
127 test("the MCP here tool repeats the line on a repository page", async () => {
128 const r = await here(aliceRw, `${servers.url}/alice/notes`);
129 expect(r.value.here.audience).toEqual(
130 audienceFor(app.db, getRepo(app.db, "alice", "notes")!, alice),
131 );
132 });
133
134 test("a fork of a private repository is private, whatever is asked", async () => {
135 const made = await forkRepo(app, alice, getRepo(app.db, "alice", "notes")!, "notes-copy", {
136 visibility: "public",
137 });
138 if (!made.ok) throw new Error(String(made.error));
139 expect(made.value.visibility).toBe("private");
140 expect(audienceFor(app.db, made.value, alice).who).toBe("owner");
141 });
142
143 test("a new app is private, and so is its repository", async () => {
144 const made = await newApp(app, alice, { name: "vault" } as never);
145 if (!made.ok) throw new Error(String(made.error));
146 const repo = getRepo(app.db, "alice", "vault")!;
147 expect(repo.visibility).toBe("private");
148 expect(getAppByRepo(app.db, repo.id)!.access).toBe("private");
149 const line = audienceFor(app.db, repo, alice);
150 expect(line.who).toBe("owner");
151 expect(line.level).toBe("private");
152 expect(line.sentence).toStartWith("Private:");
153 const owner = await here(aliceRw, appHost("vault"));
154 expect(owner.value.audience).toEqual(line);
155 });
156
157 test("the preview of a private app's pull answers its owner with the line, and a stranger like nothing", async () => {
158 const repo = getRepo(app.db, "alice", "vault")!;
159 const committed = await commitToBranch(app, alice, repo, {
160 branch: "change",
161 base: repo.defaultBranch,
162 message: "A change",
163 files: [{ path: "CHANGE.md", content: "hi\n" }],
164 });
165 if (!committed.ok) throw new Error(JSON.stringify(committed.error));
166 const opened = await openPull(app, alice, repo, {
167 title: "A change",
168 baseBranch: repo.defaultBranch,
169 headBranch: "change",
170 });
171 if (!opened.ok) throw new Error(String(opened.error));
172 pull = opened.value.number;
173 const owner = await here(aliceRw, appHost(`vault--${pull}`));
174 expect(owner.value.environment).toBe(`pull-${pull}`);
175 expect(owner.value.audience.who).toBe("owner");
176 expect(owner.value.audience.level).toBe("private");
177 expect(owner.value.audience.sentence).toStartWith("Private:");
178 expect(owner.value.audience).toEqual(audienceFor(app.db, repo, alice));
179 const stranger = await here(bobRw, appHost(`vault--${pull}`));
180 const missing = await here(bobRw, appHost("nothing"));
181 expect(stranger.error).toBeDefined();
182 expect(stranger.error).toBe(missing.error);
183 });
184
185 test("a public app on a private repository tells its visitors nothing about who can read the code", async () => {
186 const made = await createRepo(app, alice, { name: "shopfront" });
187 if (!made.ok) throw new Error(String(made.error));
188 expect(made.value.visibility).toBe("private");
189 const record = createAppRecord(app.db, { repoId: made.value.id, slug: "shopfront", access: "public" });
190 if (!record.ok) throw new Error(String(record.error));
191 const visitor = await here(bobRw, appHost("shopfront"));
192 expect(visitor.value).toBeDefined();
193 expect(visitor.value.app).toBe("shopfront");
194 expect("audience" in visitor.value).toBe(false);
195 const owner = await here(aliceRw, appHost("shopfront"));
196 expect(owner.value.audience).toEqual(audienceFor(app.db, made.value, alice));
197 });
198
199 test("changing visibility is a receipted action, and the line moves in the same answer", async () => {
200 const opened = await api("/repos/alice/notes", aliceRw, {
201 method: "PATCH",
202 body: JSON.stringify({ visibility: "public" }),
203 });
204 expect(opened.status).toBe(200);
205 const body: any = await opened.json();
206 expect(body.visibility).toBe("public");
207 expect(body.audience.who).toBe("anyone");
208 expect(body.audience.sentence).toStartWith("Public: anyone can read this;");
209 const receipt = app.db
210 .query<{ payload: string }, [string]>(
211 "SELECT payload FROM event WHERE kind = 'repo.settings_updated' AND repo_id = ?1",
212 )
213 .get(getRepo(app.db, "alice", "notes")!.id);
214 expect(JSON.parse(receipt!.payload).changed.visibility).toEqual({ from: "private", to: "public" });
215 // Now anyone may ask, and is told the same line.
216 const seen: any = await (await api("/repos/alice/notes", bobRw)).json();
217 expect(seen.audience.who).toBe("anyone");
218 const closed: any = await (
219 await api("/repos/alice/notes", aliceRw, {
220 method: "PATCH",
221 body: JSON.stringify({ visibility: "private" }),
222 })
223 ).json();
224 expect(closed.audience.who).toBe("owner");
225 expect(closed.audience.level).toBe("private");
226 });
227 });
228
229 test("who reads as one word: the owner, an organization's members, or anyone", () => {
230 expect(whoOf({ visibility: "private", ownerKind: "user" })).toBe("owner");
231 expect(whoOf({ visibility: "private", ownerKind: "org" })).toBe("members");
232 expect(whoOf({ visibility: "public", ownerKind: "user" })).toBe("anyone");
233 expect(whoOf({ visibility: "public", ownerKind: "org" })).toBe("anyone");
234 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs