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

GET a repository answers `upstream: { repo, behind, checkedAt, conflict }` for a fork, and MCP `repo` says the same #5871

Merged opened this

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

Merged

scout/forge:scout/5868-get-a-repository-answers-upstream-repo-b → main 2 commits fork 3 threads · 3 unresolved

  • Merged. scout/5868-get-a-repository-answers-upstream-repo-b was merged into main as 8e98563.

Review

Sign in to review this change.
5 files changed +149 −3
src/actions/upstream_follow.ts +26
@@ -18,6 +18,7 @@
1818 * repository at a time, whoever asks.
1919 */
2020
21 import type { Database } from "bun:sqlite";
2122 import type { App } from "../app.ts";
2223 import { getIssueById, setAssignee, updateIssueText } from "../core/issues.ts";
2324 import { listCrewRoles } from "../core/agents.ts";
@@ -425,6 +426,31 @@ export function followJson(f: UpstreamFollow): Record<string, unknown> {
425426 };
426427 }
427428
429 /**
430 * What a repository's stored follow check says, for the repository read
431 * (API and MCP alike): the upstream, how far behind, when last checked, and
432 * whether the last check found a conflict. Null for one that follows nothing,
433 * and for anyone who may not administer it: the follow is an admin's to see.
434 */
outdated

Have this take the repository and the user and return null unless canAdmin holds. Then the one helper both doors share also carries the gate, and neither door can forget it.

435 export function upstreamFacts(db: Database, repo: Repo, user: User | undefined): UpstreamFacts | null {
436 if (!canAdmin(db, repo, user)) return null;
437 const f = getFollow(db, repo.id);
438 if (f === undefined || !f.enabled) return null;
439 return {
440 repo: upstreamLabel(f.url),
441 behind: f.behind,
442 checkedAt: f.checkedAt,
443 conflict: f.error === "conflict",
444 };
445 }
446
447 export interface UpstreamFacts {
448 readonly repo: string;
449 readonly behind: number;
450 readonly checkedAt: string | null;
451 readonly conflict: boolean;
452 }
453
428454 /** `host/path` of an upstream, for a line of prose. */
429455 export function upstreamLabel(url: string): string {
430456 try {
src/contract/openapi.ts +1 −1
@@ -218,7 +218,7 @@ export const ROUTES: readonly Route[] = [
218218 method: "get",
219219 path: REPO,
220220 summary:
221 "Read one repository; `provenance` is where a migrated one came from ({ source, sourceUrl, parents: [{ fullName, url }], migratedAt }), null when it was not migrated or its owner hid it",
221 "Read one repository; `provenance` is where a migrated one came from ({ source, sourceUrl, parents: [{ fullName, url }], migratedAt }), null when it was not migrated or its owner hid it; `upstream` is the stored follow check ({ repo, behind, checkedAt, conflict }), shown to an admin of the repository, null when it follows nothing or to anyone else",
222222 auth: false,
223223 refusals: { not_found: "404: no such repository, or it is not readable by the caller" },
224224 },
src/http/api.ts +3 −1
@@ -12,6 +12,7 @@ import {
1212 checkUpstreamNow,
1313 FOLLOW_REFUSALS,
1414 followJson,
15 upstreamFacts,
1516 setFollowUpstream,
1617 } from "../actions/upstream_follow.ts";
1718 import { getFollow } from "../core/upstream_follow.ts";
@@ -2433,7 +2434,8 @@ export function apiRoutes(router: Router<Handler>): void {
24332434 const facts = timePhase(ctx.request, "counts", () => repoJson(ctx, repo));
24342435 const audience = timePhase(ctx.request, "audience", () => audienceFor(ctx.app.db, repo, ctx.auth.user));
24352436 const rendering = performance.now();
2436 const res = json({ ...facts, audience });
2437 const upstream = upstreamFacts(ctx.app.db, repo, ctx.auth.user);
outdated

High: this route has auth: false, and nothing on this line checks who is asking. Anyone who can read the repository, anonymous included, gets the upstream's host/path, how far behind it is, and whether it conflicted. The /upstream route a few lines down only shows these to admins (canAdmin). Gate this the same way and answer null for everyone else.

Nit: this database read lands inside the render phase timing. Consider timePhase(ctx.request, "upstream", () => upstreamFacts(...)) next to audience, so the phase numbers stay honest.

2438 const res = json({ ...facts, audience, upstream });
24372439 notePhase(ctx.request, "render", performance.now() - rendering);
24382440 return res;
24392441 });
src/http/mcp.ts +2 −1
@@ -62,7 +62,7 @@ import { SIGNUP_MODES } from "../settings/signup.ts";
6262 import { QUOTA_KEYS } from "../settings/quotas.ts";
6363 import { LIMIT_KEYS, limitValue } from "../settings/limits.ts";
6464 import { budgetsOf, setBudget } from "../core/subscriptions.ts";
65 import { checkUpstreamNow, FOLLOW_REFUSALS, followJson, setFollowUpstream } from "../actions/upstream_follow.ts";
65 import { checkUpstreamNow, FOLLOW_REFUSALS, followJson, setFollowUpstream, upstreamFacts } from "../actions/upstream_follow.ts";
6666 import { getFollow } from "../core/upstream_follow.ts";
6767 import { convergeWords, FOLLOW_MODE_REFUSALS, setFollowMode, severForge, takeForgeCode, takeForgeWords, takeOrTell } from "../actions/follow.ts";
6868 import { relationOf } from "../actions/forks.ts";
@@ -652,6 +652,7 @@ const TOOLS: readonly Tool[] = [
652652 repo,
653653 forkParentFor(ctx.app.db, repo, user),
654654 ),
655 upstream: upstreamFacts(ctx.app.db, repo, user),
outdated

High: same problem as the API. The follow_upstream read in this file only shows the follow to an admin (canAdmin, ~line 2789), but here every caller who can read the repository gets it.

655656 });
656657 },
657658 },
test/repo-upstream-facts.test.ts added +117
@@ -0,0 +1,117 @@
1 /** A repository read says how far behind its upstream it is, over the API and the MCP alike. */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { createRepo } from "../src/actions/repos.ts";
8 import { createToken } from "../src/core/tokens.ts";
9 import { createUser } from "../src/core/users.ts";
10 import { noteCheck, putFollow } from "../src/core/upstream_follow.ts";
11 import { serve, type Servers } from "../src/http/server.ts";
12 import { quietLogs } from "./helpers/globals.ts";
13
14 quietLogs();
15
16 let dataDir: string;
17 let app: App;
18 let servers: Servers;
19 let token = "";
20 let reader = "";
21 const cells = process.env["FORGE_CELLS"];
22
23 beforeAll(async () => {
24 process.env["FORGE_CELLS"] = "off";
25 dataDir = await mkdtemp(join(tmpdir(), "forge-repo-upstream-"));
26 app = createApp({ dataDir, port: 0 });
27 servers = await serve(app);
28 const made = await createUser(app.db, {
29 origin: "seed",
30 trusted: true,
31 username: "alice",
32 email: "[email protected]",
33 password: "a-good-passphrase",
34 adminIfFirst: true,
35 });
36 if (!made.ok) throw new Error(String(made.error));
37 token = createToken(app.db, made.value.id, "t", "ro").token;
38 const bob = await createUser(app.db, {
39 origin: "seed",
40 trusted: true,
41 username: "bob",
42 email: "[email protected]",
43 password: "a-good-passphrase",
44 });
45 if (!bob.ok) throw new Error(String(bob.error));
46 reader = createToken(app.db, bob.value.id, "r", "ro").token;
47 const fork = async (name: string, reading?: { behind: number; error: string }) => {
48 const r = await createRepo(app, made.value, { name, visibility: "public" });
49 if (!r.ok) throw new Error(String(r.error));
50 if (reading === undefined) return;
51 putFollow(app.db, { repoId: r.value.id, url: "https://example.com/up/lib.git", branch: "main", enabled: true, byId: made.value.id, originId: "" });
52 noteCheck(app.db, r.value.id, { at: "2026-10-25T10:00:00.000Z", tip: "a".repeat(40), ...reading });
53 };
54 await fork("behind", { behind: 3, error: "" });
55 await fork("level", { behind: 0, error: "" });
56 await fork("clash", { behind: 2, error: "conflict" });
57 await fork("plain");
58 });
59 afterAll(async () => {
60 await servers.stop();
61 app.close();
62 if (cells === undefined) delete process.env["FORGE_CELLS"];
63 else process.env["FORGE_CELLS"] = cells;
64 await rm(dataDir, { recursive: true, force: true });
65 });
66
67 const viaApi = async (name: string) =>
68 ((await (await fetch(`${servers.url}/api/v1/repos/alice/${name}`, { headers: { authorization: `Bearer ${token}` } })).json()) as any).upstream;
69
70 const viaMcp = async (name: string) => {
71 const res = await fetch(`${servers.url}/mcp`, {
72 method: "POST",
73 headers: { authorization: `Bearer ${token}`, "content-type": "application/json", accept: "application/json, text/event-stream" },
74 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "repo", arguments: { repo: `alice/${name}` } } }),
75 });
76 return JSON.parse(((await res.json()) as any).result.content[0].text).upstream;
77 };
78
79 const want = (behind: number, conflict: boolean) => ({
80 repo: "example.com/up/lib",
81 behind,
82 checkedAt: "2026-10-25T10:00:00.000Z",
83 conflict,
84 });
85
86 test("a fork that is behind says how far", async () => {
87 expect(await viaApi("behind")).toEqual(want(3, false));
88 expect(await viaMcp("behind")).toEqual(want(3, false));
89 });
90
91 test("a fork that is level says zero", async () => {
92 expect(await viaApi("level")).toEqual(want(0, false));
93 expect(await viaMcp("level")).toEqual(want(0, false));
94 });
95
96 test("a fork whose last check conflicted says so", async () => {
97 expect(await viaApi("clash")).toEqual(want(2, true));
98 expect(await viaMcp("clash")).toEqual(want(2, true));
99 });
100
101 test("a repository that follows nothing answers null", async () => {
102 expect(await viaApi("plain")).toBeNull();
103 expect(await viaMcp("plain")).toBeNull();
104 });
105
106 test("only an admin sees it: an anonymous reader and a non-admin reader get null", async () => {
107 const anon = ((await (await fetch(`${servers.url}/api/v1/repos/alice/behind`)).json()) as any).upstream;
108 expect(anon).toBeNull();
109 const other = ((await (await fetch(`${servers.url}/api/v1/repos/alice/behind`, { headers: { authorization: `Bearer ${reader}` } })).json()) as any).upstream;
110 expect(other).toBeNull();
111 const res = await fetch(`${servers.url}/mcp`, {
112 method: "POST",
113 headers: { authorization: `Bearer ${reader}`, "content-type": "application/json", accept: "application/json, text/event-stream" },
114 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "repo", arguments: { repo: "alice/behind" } } }),
115 });
116 expect(JSON.parse(((await res.json()) as any).result.content[0].text).upstream).toBeNull();
117 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs