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

A repository mirrored from a read-only origin is private to its owner everywhere #10637

Merged opened this

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

Merged

mason/forge:mason/10520-a-repository-mirrored-from-a-read-only-o → main 2 commits fork

  • Merged. mason/10520-a-repository-mirrored-from-a-read-only-o was merged into main as b2b93be.

Review

Sign in to review this change.
5 files changed +195 −2
docs/features/origin-read-only.md +4
@@ -7,3 +7,7 @@ Tick **Read-only** when you connect an account by token (Settings → Origins, e
77 - **Stored:** `origin.read_only`, default 0, so every connection from before stays as it was. The token stays sealed either way.
88 - **Try it:** connect with Read-only ticked, mirror a repository, comment on it: refused, and the origin saw no write.
99 - Test: `test/origin-read-only.test.ts`.
10
11 ## Private to its owner (#10520)
12
13 A repository mirrored from a read-only origin is created private, whatever the origin says, and cannot be made public while the origin is read-only: `PATCH /api/v1/repos/:owner/:name` with `"visibility": "public"` answers `409` `origin_read_only` (the same through the MCP and the settings page). Being private it is left out of every list, count, search, the changelog and Talk for anyone but its owner, by the same visibility filter as any private repository. Test: `test/origin-read-only-private.test.ts`.
src/actions/repos.ts +12 −1
@@ -4,6 +4,7 @@
44 * write path — committing files to a branch without a git client.
55 */
66
7 import { getOriginById } from "../core/origins.ts";
78 import { isContributionFork, type GuestRefusal } from "../core/contribute.ts";
89 import { contributionRefusal } from "../core/storage.ts";
910 import { limitValue } from "../settings/limits.ts";
@@ -313,7 +314,7 @@ export async function privateRepoForSealed(
313314 return sealedForkFor(app, actor, base);
314315 }
315316
316 export type UpdateSettingsError = "not_found" | "unknown_branch" | "polish_share";
317 export type UpdateSettingsError = "not_found" | "unknown_branch" | "polish_share" | "origin_read_only";
317318
318319 /** Each repository's archive transitions, one after another. */
319320 const archiveTurns = new WeakMap<App, Map<string, Promise<void>>>();
@@ -394,6 +395,12 @@ export function settingsChanged(repo: Repo, settings: RepoSettings): SettingsCha
394395 return changed;
395396 }
396397
398 /** Whether `repo` mirrors an origin that was connected read-only. */
399 export function mirrorsReadOnlyOrigin(app: App, repo: Repo): boolean {
400 if (repo.origin === null || repo.origin === undefined) return false;
401 return getOriginById(app.db, repo.origin.originId)?.readOnly === true;
402 }
403
397404 /**
398405 * Change settings. A new default branch must exist; git HEAD is moved first
399406 * and the row follows (git is truth, the index is derived from it).
@@ -421,6 +428,10 @@ export async function updateRepoSettings(
421428 // base, whoever owns it (`core/sealed.ts`).
422429 if (repo.sealedFor !== null && settings.visibility === "public")
423430 return err("not_found");
431 // A repository mirrored from a read-only origin is private to its owner
432 // for as long as the origin is read-only (#10520).
433 if (settings.visibility === "public" && mirrorsReadOnlyOrigin(app, repo))
434 return err("origin_read_only");
424435 if (settings.polishShare !== undefined && !validPolishShare(settings.polishShare))
425436 return err("polish_share");
426437 const branch = settings.defaultBranch;
src/http/api.ts +1
@@ -1667,6 +1667,7 @@ export function apiRoutes(router: Router<Handler>): void {
16671667 ? { plansNeedNod: input.plansNeedNod }
16681668 : {}),
16691669 });
1670 if (!updated.ok && updated.error === "origin_read_only") return apiError("origin_read_only", 409, "this repository mirrors a read-only origin, so it stays private to its owner; nothing was changed");
16701671 if (!updated.ok) return respondAccess(updated.error, 422);
16711672 // "Show where this came from" (#3293): an admin's, never an agent's.
16721673 if (typeof input.showProvenance === "boolean") {
src/origins/mirror.ts +2 −1
@@ -147,7 +147,8 @@ export async function mirrorRepo(
147147 const created = await createRepo(app.db, app.reposRoot, {
148148 ownerId: actor.id,
149149 name: chosen,
150 visibility: remote.private ? "private" : "public",
150 // A read-only origin's mirror is private to its owner (#10520).
151 visibility: remote.private || origin.readOnly ? "private" : "public",
151152 description: remote.description,
152153 defaultBranch,
153154 });
test/origin-read-only-private.test.ts added +176
@@ -0,0 +1,176 @@
1 /**
2 * A repository mirrored from a read-only origin is private to its owner
3 * everywhere (#10520): created private, refused a public visibility, and
4 * absent from every list a stranger or a crew agent can read, while its
5 * owner finds it. The origin's repo is public, so only the read-only flag
6 * can be what keeps the mirror closed.
7 */
8 import { afterAll, beforeAll, describe, expect, setDefaultTimeout, 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 { createSession } from "../src/core/sessions.ts";
14 import { createAgentRow } from "../src/core/agents.ts";
15 import { createUser } from "../src/core/users.ts";
16 import { getRepo } from "../src/core/repos.ts";
17 import { serve, type Servers } from "../src/http/server.ts";
18 import { syncsSettled } from "../src/origins/sync.ts";
19 import { mirrorAndWait, newFakeState, serveFakeForge, type FakeForge } from "./fake-origin.ts";
20 import { seedAdmin } from "./helpers/admin.ts";
21 import { proveAll } from "./helpers/verified.ts";
22
23 setDefaultTimeout(60_000);
24
25 const LOGIN = "octo";
26 const TOKEN = "octo-private-secret-7e2a";
27 const PASSWORD = "alice-login-secret";
28 const NAME = "quillmoth";
29 const COMMIT = "zephyrmarker first commit";
30 const basic = (u: string, p: string) => `Basic ${Buffer.from(`${u}:${p}`).toString("base64")}`;
31
32 describe("a mirror of a read-only origin is private to its owner", () => {
33 const dirs: string[] = [];
34 let app: App;
35 let servers: Servers;
36 let originApp: App;
37 let originServers: Servers;
38 let fake: FakeForge;
39 let originId = "";
40 const cookies: Record<string, string> = {};
41
42 const api = (path: string, init?: RequestInit) => {
43 const headers = new Headers(init?.headers);
44 headers.set("content-type", "application/json");
45 headers.set("authorization", basic("alice", PASSWORD));
46 return fetch(`${servers.url}${path}`, { ...init, headers });
47 };
48 const as = (who: string, path: string) =>
49 fetch(`${servers.url}${path}`, {
50 headers: cookies[who] === undefined ? {} : { cookie: cookies[who]! },
51 redirect: "manual",
52 });
53
54 beforeAll(async () => {
55 for (const p of ["forge-rop-", "forge-rop-up-"]) dirs.push(await mkdtemp(join(tmpdir(), p)));
56 originApp = createApp({ dataDir: dirs[1]!, port: 0 });
57 originServers = await serve(originApp);
58 await seedAdmin(originApp, { username: LOGIN, email: "[email protected]", password: TOKEN });
59 const oh = { "content-type": "application/json", authorization: basic(LOGIN, TOKEN) };
60 await fetch(`${originServers.url}/api/v1/repos`, {
61 method: "POST",
62 headers: oh,
63 body: JSON.stringify({ name: NAME, visibility: "public" }),
64 });
65 await fetch(`${originServers.url}/api/v1/repos/octo/${NAME}/contents`, {
66 method: "POST",
67 headers: oh,
68 body: JSON.stringify({ branch: "main", message: COMMIT, files: [{ path: "a.txt", content: "one\n" }] }),
69 });
70 const state = newFakeState("github", LOGIN, TOKEN);
71 state.repos.push({
72 id: 9,
73 fullName: `octo/${NAME}`,
74 cloneUrl: `${originServers.url}/octo/${NAME}.git`,
75 defaultBranch: "main",
76 htmlUrl: `${originServers.url}/octo/${NAME}`,
77 push: true,
78 });
79 fake = serveFakeForge(state);
80
81 app = createApp({ dataDir: dirs[0]!, port: 0 });
82 servers = await serve(app);
83 const made = await seedAdmin(app, { username: "alice", email: "[email protected]", password: PASSWORD });
84 if (!made.ok) throw new Error(made.error);
85 const bob = await createUser(app.db, { username: "bob", email: "[email protected]", password: "a-good-passphrase", origin: "seed", trusted: true });
86 const crew = await createUser(app.db, { username: "crewmate", email: "[email protected]", agent: true, origin: "agent" });
87 if (crew.ok) createAgentRow(app.db, { userId: crew.value.id, brief: "builds." });
88 if (!bob.ok || !crew.ok) throw new Error("users");
89 cookies["owner"] = `forge_session=${createSession(app.db, made.value.id).token}`;
90 cookies["stranger"] = `forge_session=${createSession(app.db, bob.value.id).token}`;
91 cookies["agent"] = `forge_session=${createSession(app.db, crew.value.id).token}`;
92
93 const connected = (await (
94 await api("/api/v1/user/origins", {
95 method: "POST",
96 body: JSON.stringify({ host: fake.url, kind: "github", token: TOKEN, readOnly: true }),
97 })
98 ).json()) as { id: string };
99 originId = connected.id;
100 await mirrorAndWait(
101 await api(`/api/v1/user/origins/${originId}/mirror`, {
102 method: "POST",
103 body: JSON.stringify({ fullName: `octo/${NAME}` }),
104 }),
105 );
106 await syncsSettled();
107 proveAll(app);
108 });
109
110 afterAll(async () => {
111 await syncsSettled();
112 fake.stop();
113 servers.stop();
114 originServers.stop();
115 app.close();
116 originApp.close();
117 for (const d of dirs) await rm(d, { recursive: true, force: true });
118 });
119
120 test("it is created private although its origin repo is public", () => {
121 const repo = getRepo(app.db, "alice", NAME);
122 expect(repo).toBeDefined();
123 expect(repo!.visibility).toBe("private");
124 });
125
126 test("it cannot be made public while the origin is read-only", async () => {
127 const res = await api(`/api/v1/repos/alice/${NAME}`, {
128 method: "PATCH",
129 body: JSON.stringify({ visibility: "public" }),
130 });
131 expect(res.status).toBe(409);
132 expect(((await res.json()) as { error: string }).error).toBe("origin_read_only");
133 expect(getRepo(app.db, "alice", NAME)!.visibility).toBe("private");
134 });
135
136 const listings = [
137 "/",
138 "/explore",
139 "/api/v1/explore",
140 "/search?q=quillmo",
141 "/api/v1/search?q=quillmo&kind=all",
142 "/api/v1/search?q=zephyrm&kind=all",
143 "/changelog",
144 "/changelog.atom",
145 "/api/v1/changelog",
146 "/talk",
147 "/alice",
148 "/api/v1/users/alice/repos",
149 ];
150
151 for (const who of ["anonymous", "stranger", "agent"]) {
152 test(`${who} finds no trace of its name or commits`, async () => {
153 for (const path of listings) {
154 const res = await as(who, path);
155 const body = await res.text();
156 expect(body, `${who} ${path}`).not.toContain(NAME);
157 expect(body, `${who} ${path}`).not.toContain("zephyrmarker");
158 }
159 });
160 }
161
162 for (const who of ["anonymous", "stranger", "agent"]) {
163 test(`${who} is told the repository does not exist`, async () => {
164 expect((await as(who, `/alice/${NAME}`)).status).toBe(404);
165 expect((await as(who, `/api/v1/repos/alice/${NAME}`)).status).toBe(404);
166 });
167 }
168
169 test("the owner finds it", async () => {
170 for (const path of ["/search?q=quillmo", "/api/v1/search?q=quillmo&kind=all", `/search?q=${NAME}`, `/api/v1/search?q=${NAME}&kind=all`, "/alice", `/alice/${NAME}`, `/api/v1/repos/alice/${NAME}`]) {
171 const res = await as("owner", path);
172 expect(res.status, path).toBeLessThan(300);
173 expect(await res.text(), path).toContain(NAME);
174 }
175 });
176 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs