Public: anyone can read this; the crew can change it.
Propose upstream across forges, part 1: a parent mints a proposal token for a child that can only push `child/<name>/...` branches and open pulls #2182
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2176-propose-upstream-across-forges-part-1-a → main 5 commits fork 8 threads · 8 unresolved
README.md +26
| @@ -77,6 +77,32 @@ real address is an opt-in under **Commit email** in `/settings` (or | ||
| 77 | 77 | `PATCH /api/v1/user` with `{"commitEmail":"account"}`); commits you push |
| 78 | 78 | yourself carry whatever your own git says. |
| 79 | 79 | |
| 80 | ### Proposal tokens | |
| 81 | ||
| 82 | A writer of a repository can hand a child (a fork, or a project on another | |
| 83 | forge) a credential that proposes and nothing more: | |
| 84 | ||
| 85 | sh | |
| 86 | curl -sX POST localhost:3000/api/v1/repos/you/hello/proposal-tokens \ | |
| 87 | -H "authorization: Bearer " \ | |
| 88 | -d '{"child":"kid","origin":"https://kid.example.com/","childRepo":"kid/hello"}' | |
| 89 | ||
| 90 | ||
| 91 | `child` is lowercase words and hyphens; `origin` an https address; | |
| 92 | `childRepo` (optional) a fork of this repository, and deleting it deletes the | |
| 93 | token, as does deleting the repository. The token expires in 30 days. It is | |
| 94 | listed as `proposal:<child>` among your tokens, and revoking it there ends it. | |
| 95 | Minting is API-only for now; a page, a terminal command and an MCP tool are | |
| 96 | part 2 (#2187). Over HTTPS it pushes only `child/<name>/…` branches of this repository (anything | |
| 97 | else is refused with one line) and `POST …/pulls` from one of them, with a | |
| 98 | body of at most 64 KiB and 20 requests a minute. Every other request is a | |
| 99 | 403, decided in `src/http/proposal_gate.ts` before any handler runs. A proposed pull | |
| 100 | runs no check and no preview until a writer approves its head (a new push | |
| 101 | waits for a new approval), and a bare push to `child/…` starts none. A pull | |
| 102 | opened with it says "proposed by child … from …" on every tab of the pull's | |
| 103 | page, in the terminal's pull view, and as `proposal` in the pull's JSON | |
| 104 | (`GET …/pulls/:number`, and MCP's `pull` tool). | |
| 105 | ||
| 80 | 106 | ## Live in the terminal |
| 81 | 107 | |
| 82 | 108 | `ssh` into the forge and you get the whole thing — repositories, code, issues, |
src/actions/pulls.ts +3
| @@ -566,6 +566,9 @@ export async function submitReview( | ||
| 566 | 566 | return stored ? ok(stored) : err("origin_refused"); |
| 567 | 567 | } |
| 568 | 568 | |
| 569 | // A proposal is opened under the minting writer's name, so that writer | |
Right per the steward's decision. The consequence is that on a one-writer forge a proposal can never run its check or preview, because |
||
| 570 | // approving it is self-review like any other: only someone else's | |
| 571 | // approval satisfies a required review. | |
| 569 | 572 | if (input.state === "approved" && pull.authorId === user.id) |
| 570 | 573 | return err("self_review"); |
| 571 | 574 |
src/core/migrations/20261016160000-proposal-tokens.ts added +30
| @@ -0,0 +1,30 @@ | ||
| 1 | // A proposal token is an ordinary token plus one row naming the parent | |
| 2 | // repository, the child it speaks for and where that child lives. Deleting | |
| 3 | // the parent, or the child repository when one is named, deletes the tokens | |
| 4 | // (and with them their rows). A pull opened with one keeps the child and | |
| 5 | // origin beside it. Tokens already without a repository are not touched. | |
| 6 | export default ` | |
| 7 | CREATE TABLE proposal_token ( | |
| 8 | token_id TEXT PRIMARY KEY REFERENCES token(id) ON DELETE CASCADE, | |
| 9 | repo_id TEXT NOT NULL, | |
| 10 | child TEXT NOT NULL, | |
| 11 | child_repo_id TEXT, | |
| 12 | origin TEXT NOT NULL | |
| 13 | ); | |
| 14 | CREATE INDEX proposal_token_repo ON proposal_token(repo_id); | |
| 15 | CREATE INDEX proposal_token_child_repo ON proposal_token(child_repo_id); | |
| 16 | ||
| 17 | CREATE TABLE pull_proposal ( | |
| 18 | issue_id TEXT PRIMARY KEY REFERENCES issue(id) ON DELETE CASCADE, | |
| 19 | child TEXT NOT NULL, | |
| 20 | origin TEXT NOT NULL | |
| 21 | ); | |
| 22 | ||
| 23 | CREATE TRIGGER proposal_token_repo_gone BEFORE DELETE ON repo | |
| 24 | BEGIN | |
| 25 | DELETE FROM token WHERE id IN ( | |
| 26 | SELECT token_id FROM proposal_token | |
| 27 | WHERE repo_id = OLD.id OR child_repo_id = OLD.id | |
| 28 | ); | |
| 29 | END; | |
| 30 | `; |
src/core/proposal_token.ts added +122
| @@ -0,0 +1,122 @@ | ||
| 1 | /** | |
| 2 | * Proposal tokens: a credential a writer of a parent repository hands to | |
| 3 | * one named child. It is a `rw` token for the writer that minted it, but | |
| 4 | * the gate in `http/proposal_gate.ts` lets it do exactly two things — push | |
| 5 | * branches under `child/<name>/` and open a pull from one. This module is | |
| 6 | * the data and the rules about names; it decides nothing about requests. | |
| 7 | */ | |
| 8 | ||
| 9 | import type { Database } from "bun:sqlite"; | |
| 10 | import { isSafeRef } from "../git/guard.ts"; | |
| 11 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 12 | import { createToken } from "./tokens.ts"; | |
| 13 | ||
| 14 | export interface Proposal { | |
| 15 | readonly tokenId: string; | |
| 16 | readonly repoId: string; | |
| 17 | readonly child: string; | |
| 18 | readonly origin: string; | |
| 19 | } | |
| 20 | ||
| 21 | /** How long a proposal token lives: a child re-mints rather than keeps one. */ | |
| 22 | export const PROPOSAL_TTL_MS = 30 * 24 * 3600_000; | |
| 23 | /** The most a proposal request may carry as a body. */ | |
| 24 | export const PROPOSAL_BODY_MAX = 64 * 1024; | |
| 25 | ||
| 26 | /** A child's name: lowercase words joined by hyphens, short. */ | |
| 27 | export const isChildName = (name: string): boolean => | |
| 28 | /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/.test(name); | |
| 29 | ||
| 30 | /** Where a child lives: an https address with no credentials, no controls. */ | |
| 31 | export function parseOrigin(raw: string): string | undefined { | |
| 32 | if (raw.length === 0 || raw.length > 200) return undefined; | |
| 33 | if (/[\x00-\x20\x7f\\]/.test(raw)) return undefined; | |
| 34 | let url: URL; | |
| 35 | try { | |
| 36 | url = new URL(raw); | |
| 37 | } catch { | |
| 38 | return undefined; | |
| 39 | } | |
| 40 | if (url.protocol !== "https:" || url.username !== "" || url.password !== "") | |
| 41 | return undefined; | |
| 42 | if (url.hash !== "" || url.search !== "") return undefined; | |
| 43 | return url.href; | |
| 44 | } | |
| 45 | ||
| 46 | /** | |
| 47 | * Whether `ref` (full `refs/heads/…`) is a branch inside the child's | |
| 48 | * namespace, `child/<name>/…`, with nothing in it git or a path could read | |
| 49 | * as a way out: every segment plain, no dots-only, no control characters. | |
| 50 | */ | |
| 51 | export function inNamespace(ref: string, child: string): boolean { | |
| 52 | const prefix = `refs/heads/child/${child}/`; | |
| 53 | if (!ref.startsWith(prefix) || ref.length > 200) return false; | |
| 54 | if (!isSafeRef(ref)) return false; | |
| 55 | const rest = ref.slice(prefix.length).split("/"); | |
| 56 | return rest.every( | |
| 57 | (part) => /^[A-Za-z0-9._-]+$/.test(part) && !/^\.+$/.test(part), | |
| 58 | ); | |
| 59 | } | |
| 60 | ||
| 61 | /** A pull's head, as the API names it (no `refs/heads/`), in the namespace. */ | |
| 62 | export const headInNamespace = (head: string, child: string): boolean => | |
| 63 | inNamespace(`refs/heads/${head}`, child); | |
| 64 | ||
| 65 | export function mintProposalToken( | |
| 66 | db: Database, | |
| 67 | userId: string, | |
| 68 | repoId: string, | |
| 69 | input: { child: string; origin: string; childRepoId?: string }, | |
| 70 | now: number = Date.now(), | |
| 71 | ): Result<{ id: string; token: string }, "invalid_child" | "invalid_origin"> { | |
| 72 | if (!isChildName(input.child)) return err("invalid_child"); | |
| 73 | const origin = parseOrigin(input.origin); | |
| 74 | if (origin === undefined) return err("invalid_origin"); | |
| 75 | const minted = createToken(db, userId, `proposal:${input.child}`, "rw", { | |
| 76 | expiresAt: new Date(now + PROPOSAL_TTL_MS).toISOString(), | |
| 77 | }); | |
| 78 | db.query( | |
| 79 | "INSERT INTO proposal_token (token_id, repo_id, child, child_repo_id, origin) VALUES (?1, ?2, ?3, ?4, ?5)", | |
| 80 | ).run(minted.id, repoId, input.child, input.childRepoId ?? null, origin); | |
| 81 | return ok(minted); | |
| 82 | } | |
| 83 | ||
| 84 | /** What a token is limited to, or undefined for an ordinary token. */ | |
| 85 | export function proposalOf(db: Database, tokenId: string): Proposal | undefined { | |
| 86 | const row = db | |
| 87 | .query< | |
| 88 | { token_id: string; repo_id: string; child: string; origin: string }, | |
| 89 | [string] | |
| 90 | >("SELECT token_id, repo_id, child, origin FROM proposal_token WHERE token_id = ?1") | |
| 91 | .get(tokenId); | |
| 92 | return row | |
| 93 | ? { tokenId: row.token_id, repoId: row.repo_id, child: row.child, origin: row.origin } | |
| 94 | : undefined; | |
| 95 | } | |
| 96 | ||
| 97 | /** The one line every surface says of a proposed pull. */ | |
| 98 | export const proposedByText = (p: { child: string; origin: string }): string => | |
| 99 | `proposed by child ${p.child} from ${p.origin}`; | |
| 100 | ||
| 101 | export function recordPullProposal( | |
| 102 | db: Database, | |
| 103 | issueId: string, | |
| 104 | p: Pick<Proposal, "child" | "origin">, | |
| 105 | ): void { | |
| 106 | db.query( | |
| 107 | "INSERT OR REPLACE INTO pull_proposal (issue_id, child, origin) VALUES (?1, ?2, ?3)", | |
| 108 | ).run(issueId, p.child, p.origin); | |
| 109 | } | |
| 110 | ||
| 111 | export function pullProposalOf( | |
| 112 | db: Database, | |
| 113 | issueId: string, | |
| 114 | ): { child: string; origin: string } | undefined { | |
| 115 | return ( | |
| 116 | db | |
| 117 | .query<{ child: string; origin: string }, [string]>( | |
| 118 | "SELECT child, origin FROM pull_proposal WHERE issue_id = ?1", | |
| 119 | ) | |
| 120 | .get(issueId) ?? undefined | |
| 121 | ); | |
| 122 | } |
src/core/pull_trust.ts +13 −3
| @@ -8,9 +8,16 @@ | ||
| 8 | 8 | * itself (only a writer could push it there), and a fork's pull opened by a |
| 9 | 9 | * writer or the crew from a fork that same person owns. A pull whose head |
| 10 | 10 | * lives in a fork on another forge (a mirror's) is someone else's code and |
| 11 | * waits like a reader's. | |
| 11 | * waits like a reader's. So does a proposal (`core/proposal_token.ts`): its | |
| 12 | * branch is in the repository itself, but the one who pushed it holds a | |
| 13 | * child's token, not a writer's hand — a pull with a proposal row, or any | |
| 14 | * head under `child/`, waits for approval whoever opened it. (The head test | |
| 15 | * covers the moment before the row is written.) A push to such a branch | |
| 16 | * with no pull starts nothing: only the default branch and an open pull's | |
| 17 | * branch are ever checked or previewed. | |
| 12 | 18 | */ |
| 13 | 19 | import type { Database } from "bun:sqlite"; |
| 20 | import { pullProposalOf } from "./proposal_token.ts"; | |
| 14 | 21 | import { getIssueById, getPull, type Pull } from "./pulls.ts"; |
| 15 | 22 | import { canWrite, getRepoById, onCrew, type Repo } from "./repos.ts"; |
| 16 | 23 | import { listReviews } from "./reviews.ts"; |
| @@ -26,8 +33,11 @@ export function pullMayRun( | ||
| 26 | 33 | pull: Pull, |
| 27 | 34 | headOid: string, |
| 28 | 35 | ): boolean { |
| 29 | if (!pull.crossRepo && pull.originHeadRepo === null) return true; | |
| 30 | if (pull.crossRepo && pull.originHeadRepo === null) { | |
| 36 | const proposed = | |
| 37 | pull.headBranch.startsWith("child/") || | |
| 38 | pullProposalOf(db, pull.id) !== undefined; | |
| 39 | if (!proposed && !pull.crossRepo && pull.originHeadRepo === null) return true; | |
| 40 | if (!proposed && pull.crossRepo && pull.originHeadRepo === null) { | |
| 31 | 41 | const author = getUserById(db, pull.authorId); |
| 32 | 42 | const head = getRepoById(db, pull.headRepoId); |
| 33 | 43 | if ( |
src/core/tokens.ts +25 −2
| @@ -8,6 +8,7 @@ import type { Database } from "bun:sqlite"; | ||
| 8 | 8 | import { sha256 } from "../kernel/hash.ts"; |
| 9 | 9 | import { id, secret } from "../kernel/id.ts"; |
| 10 | 10 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 11 | import type { Proposal } from "./proposal_token.ts"; | |
| 11 | 12 | import { getUserById, isActive, type User } from "./users.ts"; |
| 12 | 13 | |
| 13 | 14 | export type TokenScope = "ro" | "rw" | "admin"; |
| @@ -65,7 +66,10 @@ const TOUCH_EVERY_MS = 60_000; | ||
| 65 | 66 | export function verifyToken( |
| 66 | 67 | db: Database, |
| 67 | 68 | presented: string, |
| 68 | ): Result<{ user: User; scope: TokenScope }, "invalid_token"> { | |
| 69 | ): Result< | |
| 70 | { user: User; scope: TokenScope; proposal?: Proposal }, | |
| 71 | "invalid_token" | |
| 72 | > { | |
| 69 | 73 | const row = db |
| 70 | 74 | .query< |
| 71 | 75 | { |
| @@ -74,10 +78,16 @@ export function verifyToken( | ||
| 74 | 78 | scope: TokenScope; |
| 75 | 79 | last_used_at: string | null; |
| 76 | 80 | expires_at: string | null; |
| 81 | p_repo: string | null; | |
| 82 | p_child: string | null; | |
| 83 | p_origin: string | null; | |
| 77 | 84 | }, |
| 78 | 85 | [string] |
| 79 | 86 | >( |
| 80 | "SELECT id, user_id, scope, last_used_at, expires_at FROM token WHERE token_hash = ?1", | |
| 87 | `SELECT t.id, t.user_id, t.scope, t.last_used_at, t.expires_at, | |
| 88 | p.repo_id AS p_repo, p.child AS p_child, p.origin AS p_origin | |
| 89 | FROM token t LEFT JOIN proposal_token p ON p.token_id = t.id | |
| 90 | WHERE t.token_hash = ?1`, | |
| 81 | 91 | ) |
| 82 | 92 | .get(sha256(presented)); |
| 83 | 93 | if (!row) return err("invalid_token"); |
| @@ -96,6 +106,19 @@ export function verifyToken( | ||
| 96 | 106 | row.id, |
| 97 | 107 | ); |
| 98 | 108 | } |
| 109 | // A token with a proposal row is limited to it; the gate in | |
| 110 | // `http/proposal_gate.ts` reads this on every request it makes. | |
| 111 | if (row.p_repo !== null) | |
| 112 | return ok({ | |
| 113 | user, | |
| 114 | scope: row.scope, | |
| 115 | proposal: { | |
| 116 | tokenId: row.id, | |
| 117 | repoId: row.p_repo, | |
| 118 | child: row.p_child!, | |
| 119 | origin: row.p_origin!, | |
| 120 | }, | |
| 121 | }); | |
| 99 | 122 | return ok({ user, scope: row.scope }); |
| 100 | 123 | } |
| 101 | 124 |
src/git/hook-protocol.ts +2
| @@ -15,6 +15,8 @@ export interface HookRequest { | ||
| 15 | 15 | readonly hook: "pre-receive" | "post-receive"; |
| 16 | 16 | readonly repoId: string; |
| 17 | 17 | readonly actorId: string; |
| 18 | /** A proposal token's child: every ref must be inside `child/<name>/`. */ | |
| 19 | readonly proposalChild?: string; | |
| 18 | 20 | readonly updates: readonly RefUpdate[]; |
| 19 | 21 | /** |
| 20 | 22 | * Quarantine environment forwarded from the hook process. Any git command |
src/git/hooks.ts +7
| @@ -60,6 +60,8 @@ export function hookEnv(input: { | ||
| 60 | 60 | secret: string; |
| 61 | 61 | repoId: string; |
| 62 | 62 | actorId: string; |
| 63 | /** Set for a proposal token: the one namespace its push may touch. */ | |
| 64 | proposalChild?: string; | |
| 63 | 65 | }): Record<string, string> { |
| 64 | 66 | const self = selfInvocation(); |
| 65 | 67 | const env: Record<string, string> = { |
| @@ -69,6 +71,8 @@ export function hookEnv(input: { | ||
| 69 | 71 | FORGE_REPO_ID: input.repoId, |
| 70 | 72 | FORGE_ACTOR_ID: input.actorId, |
| 71 | 73 | }; |
| 74 | if (input.proposalChild !== undefined) | |
| 75 | env.FORGE_PROPOSAL_CHILD = input.proposalChild; | |
| 72 | 76 | if (self.entry !== undefined) env.FORGE_ENTRY = self.entry; |
| 73 | 77 | return env; |
| 74 | 78 | } |
| @@ -120,6 +124,9 @@ export async function runHookClient(hook: HookName): Promise<never> { | ||
| 120 | 124 | hook, |
| 121 | 125 | repoId: process.env.FORGE_REPO_ID ?? "", |
| 122 | 126 | actorId: process.env.FORGE_ACTOR_ID ?? "", |
| 127 | ...(process.env.FORGE_PROPOSAL_CHILD !== undefined | |
| 128 | ? { proposalChild: process.env.FORGE_PROPOSAL_CHILD } | |
| 129 | : {}), | |
| 123 | 130 | updates: parseHookStdin(stdin), |
| 124 | 131 | gitEnv, |
| 125 | 132 | }), |
src/http/api.ts +68 −1
| @@ -213,6 +213,13 @@ import { | ||
| 213 | 213 | } from "../core/reviews.ts"; |
| 214 | 214 | import { createSession } from "../core/sessions.ts"; |
| 215 | 215 | import { listTokens } from "../core/tokens.ts"; |
| 216 | import { | |
| 217 | headInNamespace, | |
| 218 | mintProposalToken, | |
| 219 | PROPOSAL_BODY_MAX, | |
| 220 | pullProposalOf, | |
| 221 | recordPullProposal, | |
| 222 | } from "../core/proposal_token.ts"; | |
| 216 | 223 | import { |
| 217 | 224 | getUserByName, |
| 218 | 225 | hiddenFrom, |
| @@ -3075,12 +3082,68 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3075 | 3082 | return json(paged(items, query.limit, (p) => p.number)); |
| 3076 | 3083 | }); |
| 3077 | 3084 | |
| 3085 | // A writer hands a child its own narrow credential for this repository | |
| 3086 | // (`core/proposal_token.ts`); the secret is shown once, as for any token. | |
| 3087 | router.add( | |
| 3088 | "POST", | |
| 3089 | "/api/v1/repos/:owner/:name/proposal-tokens", | |
| 3090 | async (ctx) => { | |
| 3091 | const denied = requireScope(ctx, "rw"); | |
| 3092 | if (denied) return denied; | |
| 3093 | const repo = loadRepo(ctx); | |
| 3094 | if (!repo || !canWrite(ctx.app.db, repo, ctx.auth.user)) return notFound(); | |
| 3095 | const input = await boundedBody(ctx.request, PROPOSAL_BODY_MAX); | |
| 3096 | if (input === "too_large") | |
| 3097 | return apiError("too_large", 413, `at most ${PROPOSAL_BODY_MAX} bytes`); | |
| 3098 | let childRepoId: string | undefined; | |
| 3099 | const childSpec = str(input.childRepo); | |
| 3100 | if (childSpec !== "") { | |
| 3101 | const [owner, name, ...rest] = childSpec.split("/"); | |
| 3102 | const child = | |
| 3103 | owner && name && rest.length === 0 | |
| 3104 | ? getRepo(ctx.app.db, owner, name) | |
| 3105 | : undefined; | |
| 3106 | if (!child || child.forkParentId !== repo.id) return notFound(); | |
Low: this answers 201 or 404 depending on whether a private fork of this repository exists, without checking |
||
| 3107 | childRepoId = child.id; | |
| 3108 | } | |
| 3109 | const minted = mintProposalToken(ctx.app.db, ctx.auth.user!.id, repo.id, { | |
| 3110 | child: str(input.child), | |
| 3111 | origin: str(input.origin), | |
| 3112 | ...(childRepoId ? { childRepoId } : {}), | |
| 3113 | }); | |
| 3114 | if (!minted.ok) | |
| 3115 | return apiError(minted.error, 422, "child is lowercase words and hyphens; origin is an https address"); | |
| 3116 | return json(minted.value, 201); | |
| 3117 | }, | |
| 3118 | ); | |
| 3119 | ||
| 3078 | 3120 | router.add("POST", "/api/v1/repos/:owner/:name/pulls", async (ctx) => { |
| 3079 | 3121 | const denied = requireScope(ctx, "rw"); |
| 3080 | 3122 | if (denied) return denied; |
| 3081 | 3123 | const repo = loadRepo(ctx); |
| 3082 | 3124 | if (!repo) return notFound(); |
| 3083 | const input = await body(ctx.request); | |
| 3125 | // A proposal token's body is small, and its pull comes from a branch of | |
| 3126 | // its own child's namespace in this repository, never from another one. | |
| 3127 | const proposal = ctx.auth.proposal; | |
| 3128 | // The gate already pins a proposal token to its repository; this | |
| 3129 | // repeats it so the handler is safe on its own (defense in depth). | |
| 3130 | if (proposal !== undefined && proposal.repoId !== repo.id) return forbidden(); | |
| 3131 | const input = | |
| 3132 | proposal === undefined | |
| 3133 | ? await body(ctx.request) | |
| 3134 | : await boundedBody(ctx.request, PROPOSAL_BODY_MAX); | |
| 3135 | if (input === "too_large") | |
| 3136 | return apiError("too_large", 413, `at most ${PROPOSAL_BODY_MAX} bytes`); | |
| 3137 | if ( | |
| 3138 | proposal !== undefined && | |
| 3139 | (str(input.headRepo) !== "" || | |
| 3140 | !headInNamespace(str(input.head), proposal.child)) | |
| 3141 | ) | |
| 3142 | return apiError( | |
| 3143 | "invalid_head", | |
| 3144 | 422, | |
| 3145 | `a proposal pull comes from a child/${proposal.child}/… branch of this repository`, | |
| 3146 | ); | |
| 3084 | 3147 | // `headRepo: "owner/name"` proposes a fork's branch into this base. |
| 3085 | 3148 | let headRepo: Repo | undefined; |
| 3086 | 3149 | const headSpec = str(input.headRepo); |
| @@ -3113,10 +3176,13 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3113 | 3176 | created.error === "origin_unreachable" |
| 3114 | 3177 | ? originAware(created.error) |
| 3115 | 3178 | : respondAccess(created.error, 422); |
| 3179 | if (proposal !== undefined) | |
| 3180 | recordPullProposal(ctx.app.db, created.value.id, proposal); | |
| 3116 | 3181 | return json( |
| 3117 | 3182 | { |
| 3118 | 3183 | ...withLabels(ctx, created.value), |
| 3119 | 3184 | variantSiblings: variantSiblings(ctx.app.db, [created.value])[0] ?? [], |
| 3185 | ...(proposal ? { proposal: { child: proposal.child, origin: proposal.origin } } : {}), | |
| 3120 | 3186 | }, |
| 3121 | 3187 | 201, |
| 3122 | 3188 | ); |
| @@ -3143,6 +3209,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3143 | 3209 | ...(found.pull.state === "open" && found.pull.mergedAt === null |
| 3144 | 3210 | ? { mergeable: status.mergeable, conflicts: status.conflicts } |
| 3145 | 3211 | : {}), |
| 3212 | proposal: pullProposalOf(ctx.app.db, found.pull.id) ?? null, | |
| 3146 | 3213 | status, |
| 3147 | 3214 | diff, |
| 3148 | 3215 | hold: hold === undefined ? null : holdJson(hold), |
src/http/context.ts +3
| @@ -3,6 +3,7 @@ | ||
| 3 | 3 | * route params, and authentication resolved once as plain data. |
| 4 | 4 | */ |
| 5 | 5 | |
| 6 | import type { Proposal } from "../core/proposal_token.ts"; | |
| 6 | 7 | import type { App } from "../app.ts"; |
| 7 | 8 | import type { TokenScope } from "../core/tokens.ts"; |
| 8 | 9 | import type { User } from "../core/users.ts"; |
| @@ -18,6 +19,8 @@ export interface Auth { | ||
| 18 | 19 | readonly via: "session" | "edge" | "token" | "password" | "none"; |
| 19 | 20 | /** Which signed-in browser asked, when `via` is `session`. */ |
| 20 | 21 | readonly sessionId?: string; |
| 22 | /** Set for a proposal token: all it may do (`http/proposal_gate.ts`). */ | |
| 23 | readonly proposal?: Proposal; | |
| 21 | 24 | } |
| 22 | 25 | |
| 23 | 26 | export interface Ctx { |
src/http/mcp.ts +3 −1
| @@ -85,6 +85,7 @@ import { getIssue, getIssueById, listOpenAgentPullsVisibleTo } from "../core/iss | ||
| 85 | 85 | import { waitsForPerson } from "../crew/review.ts"; |
| 86 | 86 | import { attachLook } from "../actions/attach_look.ts"; |
| 87 | 87 | import { lookUrl } from "../core/looks.ts"; |
| 88 | import { pullProposalOf } from "../core/proposal_token.ts"; | |
| 88 | 89 | import { getPull, pullDir, pullMergeState } from "../core/pulls.ts"; |
| 89 | 90 | import { repoGraph } from "../core/dependencies.ts"; |
| 90 | 91 | import { pillarView } from "../core/pillars.ts"; |
| @@ -467,7 +468,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 467 | 468 | { |
| 468 | 469 | name: "pull", |
| 469 | 470 | description: |
| 470 | "Read a pull: its title, state and head and base, and for an open pull whether it merges into the base's current head (`mergeable`) and the paths that stop it (`conflicts`), and `check`: the latest check's state and the base tip it was merged with (`baseOid`, empty when it merged nothing). `mergeable` and `conflicts` are the same fields as GET /api/v1/repos/:owner/:name/pulls/:number; `check` is the latest check's `state` and `run.baseOid` from GET …/pulls/:number/checks.", | |
| 471 | "Read a pull: its title, state and head and base, and for an open pull whether it merges into the base's current head (`mergeable`) and the paths that stop it (`conflicts`), `proposal` ({child, origin}, or null) when a child's proposal token opened it, and `check`: the latest check's state and the base tip it was merged with (`baseOid`, empty when it merged nothing). `mergeable` and `conflicts` are the same fields as GET /api/v1/repos/:owner/:name/pulls/:number; `check` is the latest check's `state` and `run.baseOid` from GET …/pulls/:number/checks.", | |
| 471 | 472 | inputSchema: { |
| 472 | 473 | type: "object", |
| 473 | 474 | properties: { |
| @@ -498,6 +499,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 498 | 499 | state: pull.mergedAt !== null ? "merged" : pull.state, |
| 499 | 500 | base: pull.baseBranch, |
| 500 | 501 | head: pull.headBranch, |
| 502 | proposal: pullProposalOf(ctx.app.db, pull.id) ?? null, | |
| 501 | 503 | check: { state: check.state, baseOid: check.run?.baseOid ?? "" }, |
| 502 | 504 | ...(state ?? {}), |
| 503 | 505 | }); |
src/http/proposal_gate.ts added +86
| @@ -0,0 +1,86 @@ | ||
| 1 | /** | |
| 2 | * The one gate every proposal-token request passes, before any handler. | |
| 3 | * | |
| 4 | * A proposal token (`core/proposal_token.ts`) may do three things at the | |
| 5 | * parent repository it was minted for and nothing else: advertise | |
| 6 | * receive-pack, run receive-pack, and open a pull. Everything is refused | |
| 7 | * unless it matches one of those exactly, so a route added tomorrow is | |
| 8 | * closed to these tokens until someone writes it in here. Which refs the | |
| 9 | * push may touch is decided at the ref update (`handleHook`), where the | |
| 10 | * refs are known; which branch the pull may come from, in the pull handler. | |
| 11 | */ | |
| 12 | ||
| 13 | import type { App } from "../app.ts"; | |
| 14 | import { getRepoById } from "../core/repos.ts"; | |
| 15 | import { RateLimiter } from "../core/ratelimit.ts"; | |
| 16 | import { PROPOSAL_BODY_MAX } from "../core/proposal_token.ts"; | |
| 17 | import type { Auth } from "./context.ts"; | |
| 18 | import { apiError } from "./respond.ts"; | |
| 19 | ||
| 20 | const limiters = new WeakMap<App, RateLimiter>(); | |
| 21 | ||
| 22 | /** Proposals per token per minute. Not switched off by FORGE_RATE_LIMIT=0. */ | |
| 23 | export const PROPOSAL_PER_MINUTE = 20; | |
| 24 | ||
| 25 | export function proposalLimiter(app: App): RateLimiter { | |
| 26 | let limiter = limiters.get(app); | |
| 27 | if (limiter === undefined) { | |
| 28 | limiter = new RateLimiter({ | |
| 29 | perMinute: PROPOSAL_PER_MINUTE, | |
| 30 | adminPerMinute: PROPOSAL_PER_MINUTE, | |
| 31 | enabled: true, | |
| 32 | }); | |
| 33 | limiters.set(app, limiter); | |
| 34 | } | |
| 35 | return limiter; | |
| 36 | } | |
| 37 | ||
| 38 | const refuse = (): Response => | |
| 39 | apiError( | |
| 40 | "forbidden", | |
| 41 | 403, | |
| 42 | "a proposal token can only push child/<name>/… branches and open pulls from them", | |
| 43 | ); | |
| 44 | ||
| 45 | /** The refusal for this request, or undefined when it may proceed. */ | |
| 46 | export function proposalGate( | |
| 47 | app: App, | |
| 48 | request: Request, | |
| 49 | url: URL, | |
| 50 | auth: Auth, | |
| 51 | ): Response | undefined { | |
| 52 | const proposal = auth.proposal; | |
| 53 | if (proposal === undefined) return undefined; | |
| 54 | ||
| 55 | const verdict = proposalLimiter(app).take(proposal.tokenId, "rw"); | |
| 56 | if (!verdict.allowed) | |
| 57 | return apiError( | |
| 58 | "rate_limited", | |
| 59 | 429, | |
| 60 | `too many proposals: ${verdict.limit} per minute per token — retry in ${verdict.retryAfter}s`, | |
| 61 | ); | |
| 62 | ||
| 63 | const parent = getRepoById(app.db, proposal.repoId); | |
| 64 | if (parent === undefined) return refuse(); | |
| 65 | const at = `/${parent.ownerName}/${parent.name}.git`; | |
| 66 | const pulls = `/api/v1/repos/${parent.ownerName}/${parent.name}/pulls`; | |
| 67 | const path = url.pathname; | |
| 68 | const method = request.method; | |
| 69 | ||
| 70 | if ( | |
| 71 | method === "GET" && | |
| 72 | path === `${at}/info/refs` && | |
| 73 | url.searchParams.get("service") === "git-receive-pack" | |
| 74 | ) | |
| 75 | return undefined; | |
| 76 | if (method === "POST" && path === `${at}/git-receive-pack`) return undefined; | |
| 77 | if (method === "POST" && path === pulls) { | |
| 78 | // An early refusal on the length the client wrote, nothing more: the | |
outdated
Low: this reads the Content-Length the client wrote. That's fine as an early 413, because |
||
| 79 | // handler's `boundedBody` counts the bytes actually read and is the cap. | |
| 80 | const declared = Number(request.headers.get("content-length") ?? 0); | |
| 81 | if (!(declared <= PROPOSAL_BODY_MAX)) | |
| 82 | return apiError("too_large", 413, `at most ${PROPOSAL_BODY_MAX} bytes`); | |
| 83 | return undefined; | |
| 84 | } | |
| 85 | return refuse(); | |
| 86 | } |
src/http/server.ts +24
| @@ -419,6 +419,8 @@ import { pushThrough } from "../origins/push-through.ts"; | ||
| 419 | 419 | import { notFoundPage } from "../web/chrome.ts"; |
| 420 | 420 | import { targetForShortCode } from "../origins/oauth.ts"; |
| 421 | 421 | import { apiRoutes } from "./api.ts"; |
| 422 | import { inNamespace } from "../core/proposal_token.ts"; | |
| 423 | import { proposalGate } from "./proposal_gate.ts"; | |
| 422 | 424 | import { feedRoutes } from "./feed.ts"; |
| 423 | 425 | import type { Database } from "bun:sqlite"; |
| 424 | 426 | import { mcpRoutes } from "./mcp.ts"; |
| @@ -522,6 +524,7 @@ async function resolveAuth(app: App, request: Request): Promise<Auth> { | ||
| 522 | 524 | user: verified.value.user, |
| 523 | 525 | scope: verified.value.scope, |
| 524 | 526 | via: "token", |
| 527 | ...(verified.value.proposal ? { proposal: verified.value.proposal } : {}), | |
| 525 | 528 | }; |
| 526 | 529 | } |
| 527 | 530 | return NO_AUTH; |
| @@ -544,6 +547,9 @@ async function resolveAuth(app: App, request: Request): Promise<Auth> { | ||
| 544 | 547 | user: verified.value.user, |
| 545 | 548 | scope: verified.value.scope, |
| 546 | 549 | via: "token", |
| 550 | ...(verified.value.proposal | |
| 551 | ? { proposal: verified.value.proposal } | |
| 552 | : {}), | |
| 547 | 553 | }; |
| 548 | 554 | } |
| 549 | 555 | return NO_AUTH; |
| @@ -951,6 +957,7 @@ export function buildRouter(): Router<Handler> { | ||
| 951 | 957 | secret: ctx.app.hookSecret, |
| 952 | 958 | repoId, |
| 953 | 959 | actorId: ctx.auth.user?.id ?? "", |
| 960 | ...(ctx.auth.proposal ? { proposalChild: ctx.auth.proposal.child } : {}), | |
| 954 | 961 | }), |
| 955 | 962 | ...cap, |
| 956 | 963 | }, |
| @@ -1648,6 +1655,18 @@ async function handleHook(app: App, request: Request): Promise<Response> { | ||
| 1648 | 1655 | ? getUserById(app.db, input.actorId) |
| 1649 | 1656 | : undefined; |
| 1650 | 1657 | for (const update of input.updates) { |
| 1658 | // A proposal token pushes inside its child's namespace and nowhere | |
| 1659 | // else: checked first, for every update, so nothing below can let a | |
| 1660 | // ref through that this refused. | |
| 1661 | if ( | |
| 1662 | input.proposalChild !== undefined && | |
| 1663 | !inNamespace(update.ref, input.proposalChild) | |
| 1664 | ) { | |
| 1665 | return new Response( | |
| 1666 | `${update.ref}: a proposal token pushes only child/${input.proposalChild}/… branches`, | |
| 1667 | { status: 403 }, | |
| 1668 | ); | |
| 1669 | } | |
| 1651 | 1670 | // The pull namespace belongs to the forge, never to pushers. |
| 1652 | 1671 | if (update.ref.startsWith("refs/pull/")) { |
| 1653 | 1672 | return new Response(`${update.ref}: refs/pull/* is reserved`, { |
| @@ -2212,6 +2231,11 @@ async function answerGet( | ||
| 2212 | 2231 | noteCaller(request, app.db, auth.user.id, auth.sessionId); |
| 2213 | 2232 | } |
| 2214 | 2233 | |
| 2234 | // A proposal token reaches nothing but what its gate names; every other | |
| 2235 | // request it makes ends here, before the limiter or any handler. | |
| 2236 | const proposalRefusal = proposalGate(app, request, url, auth); | |
| 2237 | if (proposalRefusal !== undefined) return proposalRefusal; | |
| 2238 | ||
| 2215 | 2239 | // The two things standing between one runaway agent and everybody |
| 2216 | 2240 | // else's latency, applied before a handler runs and after auth is |
| 2217 | 2241 | // known — a limit needs an identity, and a replay needs a credential. |
src/tui/screens/pull.ts +2 −1
| @@ -45,6 +45,7 @@ import { | ||
| 45 | 45 | appsRuntime, |
| 46 | 46 | type EnvState, |
| 47 | 47 | type EnvStatus, envsFor } from "../../apps/runtime.ts"; |
| 48 | import { proposedByText } from "../../core/proposal_token.ts"; | |
| 48 | 49 | import { getAppByRepo } from "../../core/apps.ts"; |
| 49 | 50 | import { getIssue, type Issue } from "../../core/issues.ts"; |
| 50 | 51 | import { pullMayRun } from "../../core/pull_trust.ts"; |
| @@ -789,7 +790,7 @@ export function pullScreen( | ||
| 789 | 790 | x1, |
| 790 | 791 | head!.y + 1, |
| 791 | 792 | truncate( |
| 792 | ` ${facts?.mergedAt != null ? "merged" : issue.state} · opened by ${who(issue.authorName, issue.originAuthor)} ${ago(issue.createdAt)}${assignedText(issueRow, ctx.icons)}`, | |
| 793 | ` ${facts?.mergedAt != null ? "merged" : issue.state} · opened by ${who(issue.authorName, issue.originAuthor)} ${ago(issue.createdAt)}${assignedText(issueRow, ctx.icons)}${facts?.proposal ? ` · ${proposedByText(facts.proposal)}` : ""}`, | |
| 793 | 794 | Math.max(0, edge - x1), |
| 794 | 795 | ), |
| 795 | 796 | style(c.mutedFg, c.bg), |
src/view/local.ts +2
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | * `canRead`; what is left is shape. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import { pullProposalOf } from "../core/proposal_token.ts"; | |
| 11 | 12 | import { mayView, sealedScope } from "../core/sealed.ts"; |
| 12 | 13 | import type { App } from "../app.ts"; |
| 13 | 14 | import { |
| @@ -306,6 +307,7 @@ export function localSource( | ||
| 306 | 307 | diff, |
| 307 | 308 | mergeable: status.mergeable, |
| 308 | 309 | conflicts: status.conflicts, |
| 310 | proposal: pullProposalOf(app.db, row.id) ?? null, | |
| 309 | 311 | reviews: listReviews(app.db, row.id).map((review) => ({ |
| 310 | 312 | author: review.originAuthor ?? review.authorName, |
| 311 | 313 | state: review.state, |
src/view/origin.ts +1
| @@ -404,6 +404,7 @@ function build( | ||
| 404 | 404 | // in a form worth rendering. Null is the honest answer. |
| 405 | 405 | mergeable: null, |
| 406 | 406 | conflicts: [], |
| 407 | proposal: null, | |
| 407 | 408 | reviews: verdicts.ok |
| 408 | 409 | ? verdicts.value.map((review) => ({ |
| 409 | 410 | author: review.author, |
src/view/source.ts +2
| @@ -181,6 +181,8 @@ export interface PullFacts { | ||
| 181 | 181 | readonly conflicts: readonly string[]; |
| 182 | 182 | /** Every verdict, newest last. Empty when nobody has reviewed. */ |
| 183 | 183 | readonly reviews: readonly ReviewFact[]; |
| 184 | /** The child and origin a proposal token opened this pull for, if one did. */ | |
| 185 | readonly proposal: { readonly child: string; readonly origin: string } | null; | |
| 184 | 186 | } |
| 185 | 187 | |
| 186 | 188 | export interface IssueDetail { |
src/web/studio/change.ts +9 −1
| @@ -9,6 +9,7 @@ | ||
| 9 | 9 | * bigger. |
| 10 | 10 | */ |
| 11 | 11 | |
| 12 | import { pullProposalOf } from "../../core/proposal_token.ts"; | |
| 12 | 13 | import { getIssueById } from "../../core/issues.ts"; |
| 13 | 14 | import { mayEditIssue } from "../../actions/issues.ts"; |
| 14 | 15 | import { marksByName, type Mark } from "../../core/marks.ts"; |
| @@ -211,7 +212,14 @@ export function changeHead(ctx: Ctx, repo: Repo, pull: Pull): Html { | ||
| 211 | 212 | >${icon("external")}view on origin</a |
| 212 | 213 | >`, |
| 213 | 214 | )} |
| 214 | ${workSpent(ctx, repo, pull.id)} | |
| 215 | ${workSpent(ctx, repo, pull.id)}${when( | |
| 216 | pullProposalOf(ctx.app.db, pull.id), | |
| 217 | (p) => | |
| 218 | html`<span class="proposed-by" | |
| 219 | >proposed by child <span class="mono">${p.child}</span> from | |
| 220 | <span class="mono">${p.origin}</span></span | |
| 221 | >`, | |
| 222 | )} | |
| 215 | 223 | </div> |
| 216 | 224 | </div>`; |
| 217 | 225 | } |
test/proposal-token.test.ts added +433
| @@ -0,0 +1,433 @@ | ||
| 1 | /** | |
| 2 | * A proposal token lets one named child push `child/<name>/…` branches to | |
| 3 | * its parent and open pulls from them — and nothing else, enforced by one | |
| 4 | * gate before any handler runs. | |
| 5 | */ | |
| 6 | ||
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { commitToBranch, createRepo, forkRepo } from "../src/actions/repos.ts"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { | |
| 14 | recordPullProposal, | |
| 15 | headInNamespace, | |
| 16 | inNamespace, | |
| 17 | isChildName, | |
| 18 | mintProposalToken, | |
| 19 | parseOrigin, | |
| 20 | } from "../src/core/proposal_token.ts"; | |
| 21 | import type { Repo } from "../src/core/repos.ts"; | |
| 22 | import { submitReview } from "../src/actions/pulls.ts"; | |
| 23 | import { pullMayRun } from "../src/core/pull_trust.ts"; | |
| 24 | import { getPull } from "../src/core/pulls.ts"; | |
| 25 | import { createToken } from "../src/core/tokens.ts"; | |
| 26 | import { createUser, type User } from "../src/core/users.ts"; | |
| 27 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 28 | import { pullScreen } from "../src/tui/screens/pull.ts"; | |
| 29 | import { local } from "../src/tui/screens/where.ts"; | |
| 30 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 31 | import { settleApp } from "./helpers/tui.ts"; | |
| 32 | import { proposalLimiter } from "../src/http/proposal_gate.ts"; | |
| 33 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 34 | import { quietLogs } from "./helpers/globals.ts"; | |
| 35 | ||
| 36 | quietLogs(); | |
| 37 | ||
| 38 | const ORIGIN = "https://kid.example.com/"; | |
| 39 | ||
| 40 | describe("the rules about names", () => { | |
| 41 | test("hostile branch names are outside every namespace", () => { | |
| 42 | const ok = "refs/heads/child/kid/feature-1"; | |
| 43 | expect(inNamespace(ok, "kid")).toBe(true); | |
| 44 | expect(inNamespace("refs/heads/child/kid/a/b.c", "kid")).toBe(true); | |
| 45 | for (const ref of [ | |
| 46 | "refs/heads/main", | |
| 47 | "refs/heads/child/other/x", | |
| 48 | "refs/heads/child/kid", | |
| 49 | "refs/heads/child/kid/", | |
| 50 | "refs/heads/child/kid/../../main", | |
| 51 | "refs/heads/child/x/../../main", | |
| 52 | "refs/heads/child/kid/..", | |
| 53 | "refs/heads/child/kid/a\nb", | |
| 54 | "refs/heads/child/kid/a\x00b", | |
| 55 | "refs/heads/child/kid/a\x7fb", | |
| 56 | "refs/heads/child/kid/a b", | |
| 57 | "refs/heads/child/kid/a:b", | |
| 58 | "refs/heads/child/kid/-x/.hidden", | |
| 59 | "refs/heads/child/kid/x.lock", | |
| 60 | "refs/heads/child/kid//x", | |
| 61 | "refs/tags/child/kid/x", | |
| 62 | "refs/pull/1/head", | |
| 63 | `refs/heads/child/kid/${"a".repeat(300)}`, | |
| 64 | ]) | |
| 65 | expect(inNamespace(ref, "kid")).toBe(false); | |
| 66 | expect(headInNamespace("child/kid/x", "kid")).toBe(true); | |
| 67 | expect(headInNamespace("child/kid/x", "kid2")).toBe(false); | |
| 68 | }); | |
| 69 | ||
| 70 | test("a child is words and hyphens; an origin is a bare https address", () => { | |
| 71 | for (const bad of ["", "Kid", "a/b", "-a", "a-", "a b", "a\nb", "x".repeat(41)]) | |
| 72 | expect(isChildName(bad)).toBe(false); | |
| 73 | expect(isChildName("kid-2")).toBe(true); | |
| 74 | for (const bad of [ | |
| 75 | "http://kid.example.com", | |
| 76 | "javascript:alert(1)", | |
| 77 | "https://u:[email protected]", | |
| 78 | "https://kid.example.com/#x", | |
| 79 | "https://kid.example.com/?x=1", | |
| 80 | "https://kid.example.com/a b", | |
| 81 | "https://kid.example.com/\n", | |
| 82 | "", | |
| 83 | ]) | |
| 84 | expect(parseOrigin(bad)).toBeUndefined(); | |
| 85 | expect(parseOrigin(ORIGIN)).toBe(ORIGIN); | |
| 86 | }); | |
| 87 | }); | |
| 88 | ||
| 89 | describe("over HTTP", () => { | |
| 90 | let dataDir: string; | |
| 91 | let app: App; | |
| 92 | let servers: Servers; | |
| 93 | let alice: User; | |
| 94 | let carol: User; | |
| 95 | let lib: Repo; | |
| 96 | let work: string; | |
| 97 | let secret: string; | |
| 98 | let tokenId: string; | |
| 99 | let proposedNumber = 0; | |
| 100 | let writerSecret: string | undefined; | |
| 101 | /** An ordinary rw token for alice, made once. */ | |
| 102 | const writerToken = (): string => | |
| 103 | (writerSecret ??= createToken(app.db, alice.id, "writer", "rw").token); | |
| 104 | ||
| 105 | const bearer = (token = secret) => ({ authorization: `Bearer ${token}` }); | |
| 106 | const api = (path: string, init: RequestInit = {}, token = secret) => | |
| 107 | fetch(`${servers.url}${path}`, { | |
| 108 | ...init, | |
| 109 | headers: { "content-type": "application/json", ...bearer(token), ...init.headers }, | |
| 110 | }); | |
| 111 | ||
| 112 | const git = async (args: string[], cwd = work) => { | |
| 113 | // No helper of the machine's may answer for the token in the URL. | |
| 114 | const env = Object.fromEntries( | |
| 115 | Object.entries(process.env).filter(([k]) => !k.startsWith("GIT_CONFIG")), | |
| 116 | ); | |
| 117 | const proc = Bun.spawn(["git", "-c", "credential.helper=", ...args], { | |
| 118 | cwd, | |
| 119 | env: { | |
| 120 | ...env, | |
| 121 | GIT_TERMINAL_PROMPT: "0", | |
| 122 | GIT_CONFIG_NOSYSTEM: "1", | |
| 123 | GIT_CONFIG_GLOBAL: "/dev/null", | |
| 124 | HOME: dataDir, | |
| 125 | }, | |
| 126 | stdout: "pipe", | |
| 127 | stderr: "pipe", | |
| 128 | }); | |
| 129 | const [out, errText] = await Promise.all([ | |
| 130 | new Response(proc.stdout).text(), | |
| 131 | new Response(proc.stderr).text(), | |
| 132 | ]); | |
| 133 | return { code: await proc.exited, out, err: errText }; | |
| 134 | }; | |
| 135 | const push = (ref: string, token = secret) => | |
| 136 | git([ | |
| 137 | "push", | |
| 138 | `${servers.url.replace("://", `://child:${token}@`)}/alice/lib.git`, | |
| 139 | `HEAD:${ref}`, | |
| 140 | ]); | |
| 141 | ||
| 142 | const mint = (child = "kid") => { | |
| 143 | const minted = mintProposalToken(app.db, alice.id, lib.id, { | |
| 144 | child, | |
| 145 | origin: ORIGIN, | |
| 146 | }); | |
| 147 | if (!minted.ok) throw new Error(minted.error); | |
| 148 | return minted.value; | |
| 149 | }; | |
| 150 | ||
| 151 | beforeAll(async () => { | |
| 152 | dataDir = await mkdtemp(join(tmpdir(), "forge-proposal-")); | |
| 153 | app = createApp({ dataDir, port: 0, host: "forge.test" }); | |
| 154 | servers = await serve(app); | |
| 155 | const people: User[] = []; | |
| 156 | for (const [username, first] of [["alice", true], ["carol", false]] as const) { | |
| 157 | const made = await createUser(app.db, { | |
| 158 | trusted: true, | |
| 159 | username, | |
| 160 | email: `${username}@forge.local`, | |
| 161 | password: "a-good-passphrase", | |
| 162 | adminIfFirst: first, | |
| 163 | }); | |
| 164 | if (!made.ok) throw new Error(made.error); | |
| 165 | people.push(made.value); | |
| 166 | } | |
| 167 | [alice, carol] = people as [User, User]; | |
| 168 | const made = await createRepo(app, alice, { name: "lib", visibility: "public" }); | |
| 169 | if (!made.ok) throw new Error(String(made.error)); | |
| 170 | lib = made.value; | |
| 171 | const seeded = await commitToBranch(app, alice, lib, { | |
| 172 | branch: "main", | |
| 173 | message: "Start", | |
| 174 | files: [{ path: "README.md", content: "hi\n" }], | |
| 175 | }); | |
| 176 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 177 | ||
| 178 | work = join(dataDir, "work"); | |
| 179 | const cloned = await git(["clone", `${servers.url}/alice/lib.git`, work], dataDir); | |
| 180 | if (cloned.code !== 0) throw new Error(cloned.err); | |
| 181 | await git(["config", "user.email", "[email protected]"]); | |
| 182 | await git(["config", "user.name", "kid"]); | |
| 183 | await Bun.write(join(work, "kid.md"), "from the kid\n"); | |
| 184 | await git(["add", "."]); | |
| 185 | await git(["commit", "-m", "Kid's change"]); | |
| 186 | ||
| 187 | ({ id: tokenId, token: secret } = mint()); | |
| 188 | }); | |
| 189 | ||
| 190 | afterAll(async () => { | |
| 191 | await servers.stop(); | |
| 192 | await rm(dataDir, { recursive: true, force: true }); | |
| 193 | }); | |
| 194 | ||
| 195 | test("a writer mints one; a reader and a stranger cannot", async () => { | |
| 196 | const aliceLogin = await api("/api/v1/tokens", { | |
| 197 | method: "POST", | |
| 198 | body: JSON.stringify({ username: "alice", password: "a-good-passphrase", scope: "rw", adminConfirm: true }), | |
| 199 | }, "none"); | |
| 200 | const writer = ((await aliceLogin.json()) as { token: string }).token; | |
| 201 | const ask = (token: string, body: object) => | |
| 202 | api("/api/v1/repos/alice/lib/proposal-tokens", { method: "POST", body: JSON.stringify(body) }, token); | |
| 203 | const good = await ask(writer, { child: "mint-test", origin: ORIGIN }); | |
| 204 | expect(good.status).toBe(201); | |
| 205 | expect(((await good.json()) as { token: string }).token).toStartWith("forge_"); | |
| 206 | expect((await ask(writer, { child: "Bad Name", origin: ORIGIN })).status).toBe(422); | |
| 207 | expect((await ask(writer, { child: "ok", origin: "http://plain.example" })).status).toBe(422); | |
| 208 | // A proposal token cannot mint another. | |
| 209 | expect((await ask(secret, { child: "again", origin: ORIGIN })).status).toBe(403); | |
| 210 | const reader = await createUser(app.db, { | |
| 211 | trusted: true, username: "dave", email: "[email protected]", password: "a-good-passphrase", | |
| 212 | }); | |
| 213 | if (!reader.ok) throw new Error(reader.error); | |
| 214 | const readerToken = createToken(app.db, reader.value.id, "r", "rw").token; | |
| 215 | expect((await ask(readerToken, { child: "x", origin: ORIGIN })).status).toBe(404); | |
| 216 | }); | |
| 217 | ||
| 218 | test("a push to the default branch is refused with one line", async () => { | |
| 219 | const pushed = await push("main"); | |
| 220 | expect(pushed.code).not.toBe(0); | |
| 221 | expect(pushed.err).toContain("a proposal token pushes only child/kid/"); | |
| 222 | }); | |
| 223 | ||
| 224 | test("a push outside the namespace is refused", async () => { | |
| 225 | for (const ref of [ | |
| 226 | "refs/heads/other", | |
| 227 | "refs/heads/child/kid2/x", | |
| 228 | "refs/heads/child/kid/../../main", | |
| 229 | "refs/tags/v1", | |
| 230 | ]) { | |
| 231 | const pushed = await push(ref); | |
| 232 | expect(pushed.code).not.toBe(0); | |
| 233 | } | |
| 234 | }); | |
| 235 | ||
| 236 | test("a push inside the namespace lands, and a pull opens from it", async () => { | |
| 237 | const pushed = await push("refs/heads/child/kid/idea"); | |
| 238 | expect(pushed.err).not.toContain("refused"); | |
| 239 | expect(pushed.code).toBe(0); | |
| 240 | ||
| 241 | const opened = await api("/api/v1/repos/alice/lib/pulls", { | |
| 242 | method: "POST", | |
| 243 | body: JSON.stringify({ | |
| 244 | title: "A <b>proposal</b>", | |
| 245 | base: "main", | |
| 246 | head: "child/kid/idea", | |
| 247 | }), | |
| 248 | }); | |
| 249 | expect(opened.status).toBe(201); | |
| 250 | const pull = (await opened.json()) as { number: number; proposal: { child: string; origin: string } }; | |
| 251 | expect(pull.proposal).toEqual({ child: "kid", origin: ORIGIN }); | |
| 252 | ||
| 253 | proposedNumber = pull.number; | |
| 254 | for (const tab of ["", "/files"]) { | |
| 255 | const text = await (await fetch(`${servers.url}/alice/lib/pulls/${pull.number}${tab}`)).text(); | |
| 256 | expect(text).toContain("proposed by child"); | |
| 257 | expect(text).toContain(ORIGIN); | |
| 258 | } | |
| 259 | }); | |
| 260 | ||
| 261 | test("every door says who proposed it: GET, MCP and the terminal", async () => { | |
| 262 | const got = (await (await api(`/api/v1/repos/alice/lib/pulls/${proposedNumber}`, {}, writerToken())).json()) as { | |
| 263 | proposal: unknown; | |
| 264 | }; | |
| 265 | expect(got.proposal).toEqual({ child: "kid", origin: ORIGIN }); | |
| 266 | ||
| 267 | const called = await fetch(`${servers.url}/mcp`, { | |
| 268 | method: "POST", | |
| 269 | headers: { | |
| 270 | "content-type": "application/json", | |
| 271 | accept: "application/json, text/event-stream", | |
| 272 | authorization: `Bearer ${writerToken()}`, | |
| 273 | }, | |
| 274 | body: JSON.stringify({ | |
| 275 | jsonrpc: "2.0", | |
| 276 | id: 1, | |
| 277 | method: "tools/call", | |
| 278 | params: { name: "pull", arguments: { repo: "alice/lib", number: proposedNumber } }, | |
| 279 | }), | |
| 280 | }); | |
| 281 | const result = ((await called.json()) as any).result.content[0].text as string; | |
| 282 | expect(JSON.parse(result).proposal).toEqual({ child: "kid", origin: ORIGIN }); | |
| 283 | ||
| 284 | const terminal = headlessTerminal(160, 44); | |
| 285 | const { app: tui, session } = createForgeApp( | |
| 286 | app, | |
| 287 | { sshUsername: "alice", remoteAddress: "127.0.0.1", fingerprint: "SHA256:a", user: alice }, | |
| 288 | { terminal, exitHooks: false }, | |
| 289 | ); | |
| 290 | tui.start(); | |
| 291 | try { | |
| 292 | tui.ctx.push(pullScreen(session, tui.ctx, local(session, lib), proposedNumber)); | |
| 293 | await settleApp(tui); | |
| 294 | expect(tui.screen.toText()).toContain(`proposed by child kid from ${ORIGIN}`); | |
| 295 | } finally { | |
| 296 | tui.stop(); | |
| 297 | } | |
| 298 | }); | |
| 299 | ||
| 300 | test("a proposed pull runs nothing until a writer approves that exact head", async () => { | |
| 301 | const pull = getPull(app.db, lib.id, proposedNumber)!; | |
| 302 | const head = pull.headOid; | |
| 303 | expect(pullMayRun(app.db, lib, pull, head)).toBe(false); | |
| 304 | // Even before its proposal row is written, a head under child/ waits. | |
| 305 | app.db.query("DELETE FROM pull_proposal WHERE issue_id = ?1").run(pull.id); | |
| 306 | expect(pullMayRun(app.db, lib, pull, head)).toBe(false); | |
| 307 | recordPullProposal(app.db, pull.id, { child: "kid", origin: ORIGIN }); | |
| 308 | // The writer who minted the token opened this pull; approving it is | |
| 309 | // self-review, and it neither runs the head nor satisfies a review. | |
| 310 | const own = await submitReview(app, alice, lib, pull, { state: "approved" }); | |
| 311 | expect(own.ok).toBe(false); | |
| 312 | if (!own.ok) expect(own.error).toBe("self_review"); | |
| 313 | expect(pullMayRun(app.db, lib, pull, head)).toBe(false); | |
| 314 | // Another writer's approval does. | |
| 315 | app.db.query("UPDATE user SET is_admin = 1 WHERE id = ?1").run(carol.id); | |
| 316 | const other = { ...carol, isAdmin: true }; | |
| 317 | const reviewed = await submitReview(app, other, lib, pull, { state: "approved" }); | |
| 318 | if (!reviewed.ok) throw new Error(String(reviewed.error)); | |
| 319 | expect(pullMayRun(app.db, lib, pull, head)).toBe(true); | |
| 320 | // A new head is a new question. | |
| 321 | expect(pullMayRun(app.db, lib, pull, "f".repeat(40))).toBe(false); | |
| 322 | }); | |
| 323 | ||
| 324 | test("the child and origin are escaped where the pull renders", async () => { | |
| 325 | // A stored value from before the name rules would still render safely. | |
| 326 | const { listPulls } = await import("../src/core/pulls.ts"); | |
| 327 | const [pull] = listPulls(app.db, lib.id, {}); | |
| 328 | recordPullProposal(app.db, pull!.id, { child: "<script>x</script>", origin: '"><img src=x>' }); | |
| 329 | const text = await (await fetch(`${servers.url}/alice/lib/pulls/${pull!.number}/files`)).text(); | |
| 330 | expect(text).not.toContain("<script>x</script>"); | |
| 331 | expect(text).toContain("<script>x</script>"); | |
| 332 | expect(text).not.toContain('"><img src=x>'); | |
| 333 | }); | |
| 334 | ||
| 335 | test("a pull from outside the namespace, or from a fork, is refused", async () => { | |
| 336 | const open = (body: object) => | |
| 337 | api("/api/v1/repos/alice/lib/pulls", { method: "POST", body: JSON.stringify({ title: "t", base: "main", ...body }) }); | |
| 338 | expect((await open({ head: "main" })).status).toBe(422); | |
| 339 | expect((await open({ head: "child/kid/../../main" })).status).toBe(422); | |
| 340 | expect((await open({ head: "child/other/x" })).status).toBe(422); | |
| 341 | expect((await open({ head: "child/kid/idea", headRepo: "carol/lib" })).status).toBe(422); | |
| 342 | }); | |
| 343 | ||
| 344 | test("an oversize body is refused", async () => { | |
| 345 | const declared = await api("/api/v1/repos/alice/lib/pulls", { | |
| 346 | method: "POST", | |
| 347 | body: JSON.stringify({ title: "t", head: "child/kid/idea", body: "x".repeat(70_000) }), | |
| 348 | }); | |
| 349 | expect(declared.status).toBe(413); | |
| 350 | const chunked = await api("/api/v1/repos/alice/lib/pulls", { | |
| 351 | method: "POST", | |
| 352 | body: new ReadableStream({ | |
| 353 | start(c) { | |
| 354 | c.enqueue(new TextEncoder().encode(`{"title":"t","head":"child/kid/idea","body":"${"x".repeat(70_000)}"}`)); | |
| 355 | c.close(); | |
| 356 | }, | |
| 357 | }), | |
| 358 | duplex: "half", | |
| 359 | } as RequestInit); | |
| 360 | expect(chunked.status).toBe(413); | |
| 361 | }); | |
| 362 | ||
| 363 | test("every other API is a 403, before its handler", async () => { | |
| 364 | const fresh = mint("sweep").token; | |
| 365 | proposalLimiter(app).setLimits({ perMinute: 1000, adminPerMinute: 1000, enabled: true }); | |
| 366 | for (const [method, path] of [ | |
| 367 | ["GET", "/api/v1/repos/alice/lib"], | |
| 368 | ["GET", "/api/v1/repos/alice/lib/pulls"], | |
| 369 | ["GET", "/api/v1/tokens"], | |
| 370 | ["POST", "/api/v1/tokens"], | |
| 371 | ["POST", "/api/v1/repos/alice/lib/issues"], | |
| 372 | ["POST", "/api/v1/repos/alice/lib/pulls/1/merge"], | |
| 373 | ["POST", "/api/v1/repos/alice/other/pulls"], | |
| 374 | ["POST", "/alice/lib.git/git-upload-pack"], | |
| 375 | ["GET", "/alice/lib.git/info/refs?service=git-upload-pack"], | |
| 376 | ["GET", "/alice/lib"], | |
| 377 | ["GET", "/nonesuch"], | |
| 378 | ] as const) { | |
| 379 | const res = await api(path, { method, ...(method === "POST" ? { body: "{}" } : {}) }, fresh); | |
| 380 | expect([method, path, res.status]).toEqual([method, path, 403]); | |
| 381 | } | |
| 382 | }); | |
| 383 | ||
| 384 | test("another repository is out of reach", async () => { | |
| 385 | const other = await createRepo(app, alice, { name: "other", visibility: "public" }); | |
| 386 | if (!other.ok) throw new Error(String(other.error)); | |
| 387 | const res = await api("/api/v1/repos/alice/other/pulls", { | |
| 388 | method: "POST", | |
| 389 | body: JSON.stringify({ title: "t", head: "child/kid/idea", base: "main" }), | |
| 390 | }, mint("far").token); | |
| 391 | expect(res.status).toBe(403); | |
| 392 | }); | |
| 393 | ||
| 394 | test("the rate limit is per token", async () => { | |
| 395 | const fresh = mint("busy"); | |
| 396 | const limiter = proposalLimiter(app); | |
| 397 | limiter.setLimits({ perMinute: 3, adminPerMinute: 3, enabled: true }); | |
| 398 | try { | |
| 399 | const codes: number[] = []; | |
| 400 | for (let i = 0; i < 5; i++) | |
| 401 | codes.push((await api("/api/v1/tokens", {}, fresh.token)).status); | |
| 402 | expect(codes).toEqual([403, 403, 403, 429, 429]); | |
| 403 | // The first token has its own budget. | |
| 404 | expect((await api("/api/v1/tokens", {}, mint("calm").token)).status).toBe(403); | |
| 405 | } finally { | |
| 406 | limiter.setLimits({ perMinute: 1000, adminPerMinute: 1000, enabled: true }); | |
| 407 | } | |
| 408 | }); | |
| 409 | ||
| 410 | test("deleting the child or the repository removes its tokens", async () => { | |
| 411 | const count = (id: string) => | |
| 412 | app.db.query<{ n: number }, [string]>("SELECT count(*) AS n FROM token WHERE id = ?1").get(id)!.n; | |
| 413 | const forked = await forkRepo(app, carol, lib, "lib-kid"); | |
| 414 | if (!forked.ok) throw new Error(String(forked.error)); | |
| 415 | const minted = mintProposalToken(app.db, alice.id, lib.id, { | |
| 416 | child: "forked", origin: ORIGIN, childRepoId: forked.value.id, | |
| 417 | }); | |
| 418 | if (!minted.ok) throw new Error(minted.error); | |
| 419 | expect(count(minted.value.id)).toBe(1); | |
| 420 | app.db.query("DELETE FROM repo WHERE id = ?1").run(forked.value.id); | |
| 421 | expect(count(minted.value.id)).toBe(0); | |
| 422 | expect(app.db.query("SELECT 1 FROM proposal_token WHERE token_id = ?1").get(minted.value.id)).toBeNull(); | |
| 423 | ||
| 424 | const scratch = await createRepo(app, alice, { name: "scratch", visibility: "private" }); | |
| 425 | if (!scratch.ok) throw new Error(String(scratch.error)); | |
| 426 | const second = mintProposalToken(app.db, alice.id, scratch.value.id, { child: "kid", origin: ORIGIN }); | |
| 427 | if (!second.ok) throw new Error(second.error); | |
| 428 | app.db.query("DELETE FROM repo WHERE id = ?1").run(scratch.value.id); | |
| 429 | expect(count(second.value.id)).toBe(0); | |
| 430 | // Others are untouched. | |
| 431 | expect(count(tokenId)).toBe(1); | |
| 432 | }); | |
| 433 | }); |
"The pull shows the child": only on its Files tab today. Also say that the token appears as
proposal:<child>in your tokens list and that revoking it there ends it.