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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9451-a-downstream-forge-works-as-a-peer-lift → main 4 commits fork 2 threads · 2 unresolved
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/ | ||
| 43 | 43 | `/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. |
| 44 | 44 | |
| 45 | 45 | 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"; | ||
| 11 | 11 | import { isSafeRef } from "../git/guard.ts"; |
| 12 | 12 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 13 | 13 | import { createToken } from "./tokens.ts"; |
| 14 | import { foldsIn } from "./trusted_sources.ts"; | |
| 14 | 15 | |
| 15 | 16 | export interface Proposal { |
| 16 | 17 | readonly tokenId: string; |
| @@ -96,8 +97,9 @@ export function proposalOf(db: Database, tokenId: string): Proposal | undefined | ||
| 96 | 97 | } |
| 97 | 98 | |
| 98 | 99 | /** 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` : ""); | |
| 101 | 103 | |
| 102 | 104 | export function recordPullProposal( |
| 103 | 105 | db: Database, |
| @@ -112,14 +114,13 @@ export function recordPullProposal( | ||
| 112 | 114 | export function pullProposalOf( |
| 113 | 115 | db: Database, |
| 114 | 116 | 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) }; | |
| 123 | 124 | } |
| 124 | 125 | |
| 125 | 126 | export interface ProposalTokenInfo { |
src/core/trusted_sources.ts +31 −2
| @@ -59,6 +59,17 @@ export function cleanAccount(raw: string): string | undefined { | ||
| 59 | 59 | return ACCOUNT.test(name) ? name : undefined; |
| 60 | 60 | } |
| 61 | 61 | |
| 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 | ||
| 62 | 73 | export type TrustError = "bad_host" | "bad_account" | "bad_date" | "in_the_past"; |
| 63 | 74 | |
| 64 | 75 | /** 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( | ||
| 69 | 80 | ): TrustedSource | TrustError { |
| 70 | 81 | const host = cleanHost(input.host); |
| 71 | 82 | if (host === undefined) return "bad_host"; |
| 72 | const account = cleanAccount(input.account); | |
| 83 | const account = cleanTrustAccount(input.account); | |
| 73 | 84 | if (account === undefined) return "bad_account"; |
| 74 | 85 | const day = input.until.trim(); |
| 75 | 86 | 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 | ||
| 276 | 287 | */ |
| 277 | 288 | export function foldsIn(db: Database, pullId: string, now: Date = new Date()): boolean { |
| 278 | 289 | if (foldInStopped(db) !== undefined) return false; |
| 279 | const from = pullSourceOf(db, pullId); | |
| 290 | const from = pullSourceOf(db, pullId) ?? proposerOf(db, pullId); | |
| 280 | 291 | if (from !== undefined) return isTrusted(db, from.host, from.account, now); |
| 281 | 292 | return localSourceTrusted(db, pullId, now); |
| 282 | 293 | } |
| @@ -312,6 +323,24 @@ function localSourceTrusted(db: Database, pullId: string, now: Date): boolean { | ||
| 312 | 323 | return row !== null && isTrusted(db, self, row.account, now); |
| 313 | 324 | } |
| 314 | 325 | |
| 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 | ||
| 315 | 344 | /** |
| 316 | 345 | * The trailers a landing from another forge carries. `Landed-via` lists |
| 317 | 346 | * 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 { | ||
| 69 | 69 | promptScreen(ctx, screen, { |
| 70 | 70 | title: "Trust a source", |
| 71 | 71 | 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", | |
| 73 | 73 | onSubmit: (raw) => { |
| 74 | 74 | const [account = "", host = "", until = ""] = raw.trim().split(/\s+/); |
| 75 | 75 | 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 { | ||
| 88 | 88 | )} |
| 89 | 89 | ${card( |
| 90 | 90 | 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> | |
| 92 | 93 | <label>Forge <input class="input" name="host" required maxlength="260" placeholder="git.open-platform.sh" autocomplete="off" /></label> |
| 93 | 94 | <label>Until <input class="input" name="until" type="date" required /></label> |
| 94 | 95 | ${btn("Trust", { icon: "check" })} |
test/proposal-token.test.ts +2 −2
| @@ -267,7 +267,7 @@ describe("over HTTP", () => { | ||
| 267 | 267 | const got = (await (await api(`/api/v1/repos/alice/lib/pulls/${proposedNumber}`, {}, writerToken())).json()) as { |
| 268 | 268 | proposal: unknown; |
| 269 | 269 | }; |
| 270 | expect(got.proposal).toEqual({ child: "kid", origin: ORIGIN }); | |
| 270 | expect(got.proposal).toEqual({ child: "kid", origin: ORIGIN, folds: false }); | |
| 271 | 271 | |
| 272 | 272 | const called = await fetch(`${servers.url}/mcp`, { |
| 273 | 273 | method: "POST", |
| @@ -284,7 +284,7 @@ describe("over HTTP", () => { | ||
| 284 | 284 | }), |
| 285 | 285 | }); |
| 286 | 286 | 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 }); | |
| 288 | 288 | |
| 289 | 289 | const terminal = headlessTerminal(160, 44); |
| 290 | 290 | 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); | |
| 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 | }); |
new above
offline
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.