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

A downstream forge works as a peer: lift the limits on forges that follow this one #9553

Merged opened this in answer to

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

Merged

scout/forge:scout/9451-a-downstream-forge-works-as-a-peer-lift → main 4 commits fork 2 threads · 2 unresolved

  • Merged. scout/9451-a-downstream-forge-works-as-a-peer-lift was merged into main as dd9b1e0.

Review

Sign in to review this change.
7 files changed +201 −16
docs/features/forge-origin.md +18
@@ -43,3 +43,21 @@ The preview of the pull was not exercised: the write-through is proven by `test/
4343 `/settings/origins/:id/repos` and `GET /api/v1/user/origins/:id/repos?group=yours|shared|public&q=&cursor=` show three groups, read live, searched and paged: repositories the account owns, ones it was let into, and what anyone may read on that forge. Owning nothing there is not an empty list. A forge is read by `GET /api/v1/repos` on every release, so "shared" means another account's private repository or anything in an organization the account belongs to; a public repository one person was given write on reads as public, since a forge's JSON carries no rights. Forgejo and Gitea use `/repos/search`. GitHub sorts the account's own list into yours and shared and has no public group.
4444
4545 Any of them can be mirrored here, and a mirror follows the one there. Sending work back from a repository someone else owns takes a token that can write there: `contribute` forks it there and opens a pull, and a read-only connection is refused in words. Test: `test/origin-repo-groups.test.ts`.
46
47 ## A downstream forge as a peer (#9451)
48
49 A child forge's proposal (`child/<name>/…` head, proposal token) used to wait for a person to approve the exact head even when that forge was trusted. Now a writer trusts the pair *(forge, `child:<name>`)* in the same trusted-sources list as an origin's account, on the same page (`/settings/trusted`) and in the same terminal screen: write `child:kid` as the account. The forge is the host of the origin the proposal token was minted for; the name is the one the writer chose when minting it. Its pulls then run their checks and fold in like a trusted origin's (`foldsIn`, `src/core/trusted_sources.ts`).
50
51 **Why the two namespaces cannot cross.** Origin accounts are cleaned by `cleanAccount`, which refuses `:`, and `sourceOfUrl` and mirrored authors use it, so no origin account can ever be spelled `child:kid`. A child is looked up only as `child:<name>`. Trusting person `kid` on a host does not trust a child `kid` there, nor the reverse (test: "an origin account never trusts a child of the same name").
52
53 **Why the origin is not an unchecked claim.** Only a person who can write this repository mints a proposal token, and they type the origin and name; the pull's proposal row is written only by a request carrying that token (`proposal_gate`). A claimed host is therefore the writer's own statement, and nothing is trusted until a writer also trusts that exact pair. Nothing here verifies the forge answers at that host; trust is "work pushed with the token I gave that forge".
54
55 **Seeing why it ran.** The proposal line on a pull, in the web, terminal, API (`proposal.folds`) and MCP, says "trusted as child:<name> (trusted sources), so its checks run" when it does.
56
57 What still protects this forge:
58 - Trust is by date and ends on its own; a different name or a different forge is a stranger.
59 - The same stop switch (`stopFoldIn`) ends it at once, for origins and children alike.
60 - The same walls hold: `foldWall` still holds secrets, permissions and release machinery for a person, and `needsCrewReview` is unchanged (tests: "a trusted child's pull meets the same walls"). The token's gate still allows only reading, pushing under `child/<name>/` and opening a pull.
61 - An untrusted child's proposals still wait for a person to approve the exact head.
62
63 Tests: `test/trusted-downstream.test.ts`. The other limits of #9451 are separate issues linked to it.
src/core/proposal_token.ts +11 −10
@@ -11,6 +11,7 @@ import type { Database } from "bun:sqlite";
1111 import { isSafeRef } from "../git/guard.ts";
1212 import { err, ok, type Result } from "../kernel/result.ts";
1313 import { createToken } from "./tokens.ts";
14 import { foldsIn } from "./trusted_sources.ts";
1415
1516 export interface Proposal {
1617 readonly tokenId: string;
@@ -96,8 +97,9 @@ export function proposalOf(db: Database, tokenId: string): Proposal | undefined
9697 }
9798
9899 /** The one line every surface says of a proposed pull. */
99 export const proposedByText = (p: { child: string; origin: string }): string =>
100 `proposed by child ${p.child} from ${p.origin}`;
100 export const proposedByText = (p: { child: string; origin: string; folds?: boolean }): string =>
101 `proposed by child ${p.child} from ${p.origin}` +
102 (p.folds === true ? `, trusted as child:${p.child} (trusted sources), so its checks run` : "");
101103
102104 export function recordPullProposal(
103105 db: Database,
@@ -112,14 +114,13 @@ export function recordPullProposal(
112114 export function pullProposalOf(
113115 db: Database,
114116 issueId: string,
115 ): { child: string; origin: string } | undefined {
116 return (
117 db
118 .query<{ child: string; origin: string }, [string]>(
119 "SELECT child, origin FROM pull_proposal WHERE issue_id = ?1",
120 )
121 .get(issueId) ?? undefined
122 );
117 ): { child: string; origin: string; folds: boolean } | undefined {
118 const row = db
119 .query<{ child: string; origin: string }, [string]>(
120 "SELECT child, origin FROM pull_proposal WHERE issue_id = ?1",
121 )
122 .get(issueId);
123 return row === null ? undefined : { ...row, folds: foldsIn(db, issueId) };
123124 }
124125
125126 export interface ProposalTokenInfo {
src/core/trusted_sources.ts +31 −2
@@ -59,6 +59,17 @@ export function cleanAccount(raw: string): string | undefined {
5959 return ACCOUNT.test(name) ? name : undefined;
6060 }
6161
62 /** A downstream forge's proposals are trusted under this prefix; no origin account can carry it (`cleanAccount` refuses ":"). */
63 export const CHILD_PREFIX = "child:";
64
65 /** The account a trust row is kept under: an origin's account, or `child:<name>` for a downstream forge's proposals. */
66 export function cleanTrustAccount(raw: string): string | undefined {
67 const text = raw.trim().toLowerCase();
68 if (!text.startsWith(CHILD_PREFIX)) return cleanAccount(text);
69 const name = text.slice(CHILD_PREFIX.length);
70 return /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/.test(name) ? text : undefined;
71 }
72
6273 export type TrustError = "bad_host" | "bad_account" | "bad_date" | "in_the_past";
6374
6475 /** Keep (or renew) a source. The end is a calendar date, read as the end of that UTC day. */
@@ -69,7 +80,7 @@ export function trustSource(
6980 ): TrustedSource | TrustError {
7081 const host = cleanHost(input.host);
7182 if (host === undefined) return "bad_host";
72 const account = cleanAccount(input.account);
83 const account = cleanTrustAccount(input.account);
7384 if (account === undefined) return "bad_account";
7485 const day = input.until.trim();
7586 if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) return "bad_date";
@@ -276,7 +287,7 @@ export function pullIdsFrom(db: Database, host: string, account: string): string
276287 */
277288 export function foldsIn(db: Database, pullId: string, now: Date = new Date()): boolean {
278289 if (foldInStopped(db) !== undefined) return false;
279 const from = pullSourceOf(db, pullId);
290 const from = pullSourceOf(db, pullId) ?? proposerOf(db, pullId);
280291 if (from !== undefined) return isTrusted(db, from.host, from.account, now);
281292 return localSourceTrusted(db, pullId, now);
282293 }
@@ -312,6 +323,24 @@ function localSourceTrusted(db: Database, pullId: string, now: Date): boolean {
312323 return row !== null && isTrusted(db, self, row.account, now);
313324 }
314325
326 /**
327 * A downstream forge's proposal (#9451) is a source like any other: the
328 * forge is the host of the origin the proposal token was minted for, the
329 * account is `child:<name>`, the name a writer here chose when minting the
330 * token (the origin is typed by that writer too; only a writer can mint, and
331 * a proposal's pull is opened through that token). The prefix keeps it from
332 * ever equalling an origin's account. A writer trusts that pair exactly as they trust an origin's account, so it ends by
333 * date, the same walls apply and the same stop switch stops it.
334 */
335 function proposerOf(db: Database, pullId: string): { host: string; account: string } | undefined {
336 const row = db
337 .query<{ child: string; origin: string }, [string]>("SELECT child, origin FROM pull_proposal WHERE issue_id = ?1")
338 .get(pullId);
339 if (row === null) return undefined;
340 const host = cleanHost(row.origin);
341 return host === undefined ? undefined : { host, account: `${CHILD_PREFIX}${row.child}` };
342 }
343
315344 /**
316345 * The trailers a landing from another forge carries. `Landed-via` lists
317346 * every forge the work passed through, this one last: a forge that finds
src/tui/screens/trusted.ts +1 −1
@@ -69,7 +69,7 @@ export function trustedScreen(f: Session, ctx: Ctx): Screen {
6969 promptScreen(ctx, screen, {
7070 title: "Trust a source",
7171 label: "account forge until, e.g. jmoran git.open-platform.sh 2026-12-02",
72 hint: "account, forge, end date",
72 hint: "account (child:name for a downstream forge), forge, end date",
7373 onSubmit: (raw) => {
7474 const [account = "", host = "", until = ""] = raw.trim().split(/\s+/);
7575 const made = addTrust(f.app, user, { account, host, until });
src/web/pages/trusted.ts +2 −1
@@ -88,7 +88,8 @@ export function trustedPage(ctx: Ctx): Response {
8888 )}
8989 ${card(
9090 html`<form method="post" action="/settings/trusted" class="stack">
91 <label>Account <input class="input" name="account" required maxlength="64" autocomplete="off" /></label>
91 <label>Account <input class="input" name="account" required maxlength="64" autocomplete="off" aria-describedby="acct-hint" /></label>
92 <p id="acct-hint" class="muted small">For a downstream forge's proposals write <code>child:name</code>, the name its proposal token was minted for.</p>
9293 <label>Forge <input class="input" name="host" required maxlength="260" placeholder="git.open-platform.sh" autocomplete="off" /></label>
9394 <label>Until <input class="input" name="until" type="date" required /></label>
9495 ${btn("Trust", { icon: "check" })}
test/proposal-token.test.ts +2 −2
@@ -267,7 +267,7 @@ describe("over HTTP", () => {
267267 const got = (await (await api(`/api/v1/repos/alice/lib/pulls/${proposedNumber}`, {}, writerToken())).json()) as {
268268 proposal: unknown;
269269 };
270 expect(got.proposal).toEqual({ child: "kid", origin: ORIGIN });
270 expect(got.proposal).toEqual({ child: "kid", origin: ORIGIN, folds: false });
271271
272272 const called = await fetch(`${servers.url}/mcp`, {
273273 method: "POST",
@@ -284,7 +284,7 @@ describe("over HTTP", () => {
284284 }),
285285 });
286286 const result = ((await called.json()) as any).result.content[0].text as string;
287 expect(JSON.parse(result).proposal).toEqual({ child: "kid", origin: ORIGIN });
287 expect(JSON.parse(result).proposal).toEqual({ child: "kid", origin: ORIGIN, folds: false });
288288
289289 const terminal = headlessTerminal(160, 44);
290290 const { app: tui, session } = createForgeApp(
test/trusted-downstream.test.ts added +136
@@ -0,0 +1,136 @@
1 /**
2 * A trusted downstream forge's proposals run and fold in like a trusted
3 * origin's (#9451): by date, behind the same stop switch, and only for the
4 * pair (forge, child name) a writer trusted.
5 */
6 import { afterAll, beforeAll, 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 { openPull } from "../src/actions/pulls.ts";
11 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
12 import { createApp, type App } from "../src/app.ts";
13 import { proposedByText, pullProposalOf, recordPullProposal } from "../src/core/proposal_token.ts";
14 import { pullMayRun } from "../src/core/pull_trust.ts";
15 import { getPull } from "../src/core/pulls.ts";
16 import type { Repo } from "../src/core/repos.ts";
17 import { foldWall } from "../src/crew/fold_walls.ts";
18 import { foldsIn, needsCrewReview, recordPullSource, resumeFoldIn, stopFoldIn, trustSource, untrust } from "../src/core/trusted_sources.ts";
19 import { createUser, type User } from "../src/core/users.ts";
20 import { quietLogs } from "./helpers/globals.ts";
21
22 quietLogs();
23
24 let dataDir: string;
25 let app: App;
26 let alice: User;
27 let lib: Repo;
28 let baseOid: string;
29
30 beforeAll(async () => {
31 dataDir = await mkdtemp(join(tmpdir(), "forge-downstream-"));
32 app = createApp({ dataDir, port: 0, host: "forge.test" });
33 const made = await createUser(app.db, {
34 origin: "seed",
35 trusted: true,
36 username: "alice",
37 email: "[email protected]",
38 password: "a-good-passphrase",
39 adminIfFirst: true,
40 });
41 if (!made.ok) throw new Error(made.error);
42 alice = made.value;
43 const repo = await createRepo(app, alice, { name: "lib", visibility: "public" });
44 if (!repo.ok) throw new Error(String(repo.error));
45 lib = repo.value;
46 const base = await commitToBranch(app, alice, lib, { message: "Start", files: [{ path: "README.md", content: "hi\n" }] });
47 if (!base.ok) throw new Error(JSON.stringify(base.error));
48 baseOid = base.value.commitOid;
49 }, 120_000);
50
51 afterAll(async () => {
52 app.close();
53 await rm(dataDir, { recursive: true, force: true });
54 });
55
56 const propose = async (child: string, origin: string, file = `docs/${child}.md`) => {
57 const branch = `child/${child}/work`;
58 const done = await commitToBranch(app, alice, lib, {
59 branch,
60 message: "work",
61 files: [{ path: file, content: "x\n" }],
62 });
63 if (!done.ok) throw new Error(JSON.stringify(done.error));
64 const opened = await openPull(app, alice, lib, { title: `From ${child}`, baseBranch: "main", headBranch: branch });
65 if (!opened.ok) throw new Error(String(opened.error));
66 recordPullProposal(app.db, opened.value.id, { child, origin });
67 return { pull: () => getPull(app.db, lib.id, opened.value.number)!, head: done.value.commitOid };
68 };
69
70 test("a child's proposal waits until its forge and name are trusted, then runs; the stop switch and a lapse end it", async () => {
71 const { pull, head } = await propose("kid", "https://kid.example.com/");
72 expect(pullMayRun(app.db, lib, pull(), head)).toBe(false);
73
74 // A different name or a different forge is a stranger.
75 const other = trustSource(app.db, { host: "kid.example.com", account: "child:other", until: "2099-01-01", setBy: alice.id });
76 if (typeof other === "string") throw new Error(other);
outdated

Missing: an origin account with the same name as the child must not trust the child, and the walls (foldWall/needsCrewReview) are not exercised with a child pull.

77 trustSource(app.db, { host: "evil.example.com", account: "child:kid", until: "2099-01-01", setBy: alice.id });
78 expect(pullMayRun(app.db, lib, pull(), head)).toBe(false);
79
80 const kid = trustSource(app.db, { host: "kid.example.com", account: "child:kid", until: "2099-01-01", setBy: alice.id });
81 if (typeof kid === "string") throw new Error(kid);
82 expect(foldsIn(app.db, pull().id)).toBe(true);
83 expect(pullMayRun(app.db, lib, pull(), head)).toBe(true);
84
85 stopFoldIn(app.db, alice.id);
86 expect(pullMayRun(app.db, lib, pull(), head)).toBe(false);
87 resumeFoldIn(app.db);
88 expect(pullMayRun(app.db, lib, pull(), head)).toBe(true);
89
90 expect(foldsIn(app.db, pull().id, new Date("2100-01-01"))).toBe(false);
91 untrust(app.db, kid.id);
92 expect(pullMayRun(app.db, lib, pull(), head)).toBe(false);
93 });
94
95 test("an origin account never trusts a child of the same name, nor the reverse", async () => {
96 const { pull, head } = await propose("twin", "https://twin.example.com/");
97 // The origin's account `twin` on that very host is a person there, not the forge peer.
98 const person = trustSource(app.db, { host: "twin.example.com", account: "twin", until: "2099-01-01", setBy: alice.id });
99 if (typeof person === "string") throw new Error(person);
100 expect(foldsIn(app.db, pull().id)).toBe(false);
101 expect(pullMayRun(app.db, lib, pull(), head)).toBe(false);
102
103 // And trusting the child does not trust that person's pull from the origin.
104 untrust(app.db, person.id);
105 const child = trustSource(app.db, { host: "twin.example.com", account: "child:twin", until: "2099-01-01", setBy: alice.id });
106 if (typeof child === "string") throw new Error(child);
107 expect(foldsIn(app.db, pull().id)).toBe(true);
108 const originPull = await propose("plain", "https://plain.example.com/");
109 recordPullSource(app.db, { pullId: originPull.pull().id, host: "twin.example.com", account: "twin", via: [] });
110 app.db.query("DELETE FROM pull_proposal WHERE issue_id = ?1").run(originPull.pull().id);
111 expect(foldsIn(app.db, originPull.pull().id)).toBe(false);
112
113 // The prefix is not a way to name an origin account, and a bad child name is refused.
114 expect(trustSource(app.db, { host: "x.example.com", account: "child:", until: "2099-01-01", setBy: alice.id })).toBe("bad_account");
115 expect(trustSource(app.db, { host: "x.example.com", account: "child:a b", until: "2099-01-01", setBy: alice.id })).toBe("bad_account");
116 });
117
118 test("a trusted child's pull meets the same walls, and the page line says why it ran", async () => {
119 trustSource(app.db, { host: "wall.example.com", account: "child:wall", until: "2099-01-01", setBy: alice.id });
120 const calm = await propose("wall", "https://wall.example.com/", "docs/calm.md");
121 expect(foldsIn(app.db, calm.pull().id)).toBe(true);
122 expect(needsCrewReview(app.db, calm.pull().id)).toBe(false);
123 expect(await foldWall(app, lib, calm.pull(), baseOid)).toEqual([]);
124 expect(proposedByText(pullProposalOf(app.db, calm.pull().id)!)).toContain("trusted as child:wall");
125
126 // Same trust, but the diff touches a secret: a person decides.
127 trustSource(app.db, { host: "wall2.example.com", account: "child:wall2", until: "2099-01-01", setBy: alice.id });
128 const risky = await propose("wall2", "https://wall2.example.com/", ".env");
129 expect(foldsIn(app.db, risky.pull().id)).toBe(true);
130 const walls = await foldWall(app, lib, risky.pull(), baseOid);
131 expect(walls.join(" ")).toContain("secrets or credentials");
132
133 // A stranger's proposal says nothing of trust.
134 const stranger = await propose("stranger", "https://stranger.example.com/");
135 expect(proposedByText(pullProposalOf(app.db, stranger.pull().id)!)).not.toContain("trusted");
136 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs