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

Merged opened this

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

Merged

scout/forge:scout/2176-propose-upstream-across-forges-part-1-a → main 5 commits fork 8 threads · 8 unresolved

  • Merged. scout/2176-propose-upstream-across-forges-part-1-a was merged into main as 7dcf24c.

Review

Sign in to review this change.
19 files changed +861 −9
README.md +26
@@ -77,6 +77,32 @@ real address is an opt-in under **Commit email** in `/settings` (or
7777 `PATCH /api/v1/user` with `{"commitEmail":"account"}`); commits you push
7878 yourself carry whatever your own git says.
7979
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 $TOKEN" \
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
outdated

"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.

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
80106 ## Live in the terminal
81107
82108 `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(
566566 return stored ? ok(stored) : err("origin_refused");
567567 }
568568
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 approveHere refuses it as not_foreign. That's tracked in #2212 as a run-only grant that doesn't count as review.

570 // approving it is self-review like any other: only someone else's
571 // approval satisfies a required review.
569572 if (input.state === "approved" && pull.authorId === user.id)
570573 return err("self_review");
571574
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 @@
88 * itself (only a writer could push it there), and a fork's pull opened by a
99 * writer or the crew from a fork that same person owns. A pull whose head
1010 * 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.
1218 */
1319 import type { Database } from "bun:sqlite";
20 import { pullProposalOf } from "./proposal_token.ts";
1421 import { getIssueById, getPull, type Pull } from "./pulls.ts";
1522 import { canWrite, getRepoById, onCrew, type Repo } from "./repos.ts";
1623 import { listReviews } from "./reviews.ts";
@@ -26,8 +33,11 @@ export function pullMayRun(
2633 pull: Pull,
2734 headOid: string,
2835 ): 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) {
3141 const author = getUserById(db, pull.authorId);
3242 const head = getRepoById(db, pull.headRepoId);
3343 if (
src/core/tokens.ts +25 −2
@@ -8,6 +8,7 @@ import type { Database } from "bun:sqlite";
88 import { sha256 } from "../kernel/hash.ts";
99 import { id, secret } from "../kernel/id.ts";
1010 import { err, ok, type Result } from "../kernel/result.ts";
11 import type { Proposal } from "./proposal_token.ts";
1112 import { getUserById, isActive, type User } from "./users.ts";
1213
1314 export type TokenScope = "ro" | "rw" | "admin";
@@ -65,7 +66,10 @@ const TOUCH_EVERY_MS = 60_000;
6566 export function verifyToken(
6667 db: Database,
6768 presented: string,
68 ): Result<{ user: User; scope: TokenScope }, "invalid_token"> {
69 ): Result<
70 { user: User; scope: TokenScope; proposal?: Proposal },
71 "invalid_token"
72 > {
6973 const row = db
7074 .query<
7175 {
@@ -74,10 +78,16 @@ export function verifyToken(
7478 scope: TokenScope;
7579 last_used_at: string | null;
7680 expires_at: string | null;
81 p_repo: string | null;
82 p_child: string | null;
83 p_origin: string | null;
7784 },
7885 [string]
7986 >(
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`,
8191 )
8292 .get(sha256(presented));
8393 if (!row) return err("invalid_token");
@@ -96,6 +106,19 @@ export function verifyToken(
96106 row.id,
97107 );
98108 }
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 });
99122 return ok({ user, scope: row.scope });
100123 }
101124
src/git/hook-protocol.ts +2
@@ -15,6 +15,8 @@ export interface HookRequest {
1515 readonly hook: "pre-receive" | "post-receive";
1616 readonly repoId: string;
1717 readonly actorId: string;
18 /** A proposal token's child: every ref must be inside `child/<name>/`. */
19 readonly proposalChild?: string;
1820 readonly updates: readonly RefUpdate[];
1921 /**
2022 * Quarantine environment forwarded from the hook process. Any git command
src/git/hooks.ts +7
@@ -60,6 +60,8 @@ export function hookEnv(input: {
6060 secret: string;
6161 repoId: string;
6262 actorId: string;
63 /** Set for a proposal token: the one namespace its push may touch. */
64 proposalChild?: string;
6365 }): Record<string, string> {
6466 const self = selfInvocation();
6567 const env: Record<string, string> = {
@@ -69,6 +71,8 @@ export function hookEnv(input: {
6971 FORGE_REPO_ID: input.repoId,
7072 FORGE_ACTOR_ID: input.actorId,
7173 };
74 if (input.proposalChild !== undefined)
75 env.FORGE_PROPOSAL_CHILD = input.proposalChild;
7276 if (self.entry !== undefined) env.FORGE_ENTRY = self.entry;
7377 return env;
7478 }
@@ -120,6 +124,9 @@ export async function runHookClient(hook: HookName): Promise<never> {
120124 hook,
121125 repoId: process.env.FORGE_REPO_ID ?? "",
122126 actorId: process.env.FORGE_ACTOR_ID ?? "",
127 ...(process.env.FORGE_PROPOSAL_CHILD !== undefined
128 ? { proposalChild: process.env.FORGE_PROPOSAL_CHILD }
129 : {}),
123130 updates: parseHookStdin(stdin),
124131 gitEnv,
125132 }),
src/http/api.ts +68 −1
@@ -213,6 +213,13 @@ import {
213213 } from "../core/reviews.ts";
214214 import { createSession } from "../core/sessions.ts";
215215 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";
216223 import {
217224 getUserByName,
218225 hiddenFrom,
@@ -3075,12 +3082,68 @@ export function apiRoutes(router: Router<Handler>): void {
30753082 return json(paged(items, query.limit, (p) => p.number));
30763083 });
30773084
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 canRead(ctx.app.db, child, ctx.auth.user). Add that check to the condition so the oracle closes, and to make sure the minter is linking a fork they can actually see.

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
30783120 router.add("POST", "/api/v1/repos/:owner/:name/pulls", async (ctx) => {
30793121 const denied = requireScope(ctx, "rw");
30803122 if (denied) return denied;
30813123 const repo = loadRepo(ctx);
30823124 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 );
30843147 // `headRepo: "owner/name"` proposes a fork's branch into this base.
30853148 let headRepo: Repo | undefined;
30863149 const headSpec = str(input.headRepo);
@@ -3113,10 +3176,13 @@ export function apiRoutes(router: Router<Handler>): void {
31133176 created.error === "origin_unreachable"
31143177 ? originAware(created.error)
31153178 : respondAccess(created.error, 422);
3179 if (proposal !== undefined)
3180 recordPullProposal(ctx.app.db, created.value.id, proposal);
31163181 return json(
31173182 {
31183183 ...withLabels(ctx, created.value),
31193184 variantSiblings: variantSiblings(ctx.app.db, [created.value])[0] ?? [],
3185 ...(proposal ? { proposal: { child: proposal.child, origin: proposal.origin } } : {}),
31203186 },
31213187 201,
31223188 );
@@ -3143,6 +3209,7 @@ export function apiRoutes(router: Router<Handler>): void {
31433209 ...(found.pull.state === "open" && found.pull.mergedAt === null
31443210 ? { mergeable: status.mergeable, conflicts: status.conflicts }
31453211 : {}),
3212 proposal: pullProposalOf(ctx.app.db, found.pull.id) ?? null,
31463213 status,
31473214 diff,
31483215 hold: hold === undefined ? null : holdJson(hold),
src/http/context.ts +3
@@ -3,6 +3,7 @@
33 * route params, and authentication resolved once as plain data.
44 */
55
6 import type { Proposal } from "../core/proposal_token.ts";
67 import type { App } from "../app.ts";
78 import type { TokenScope } from "../core/tokens.ts";
89 import type { User } from "../core/users.ts";
@@ -18,6 +19,8 @@ export interface Auth {
1819 readonly via: "session" | "edge" | "token" | "password" | "none";
1920 /** Which signed-in browser asked, when `via` is `session`. */
2021 readonly sessionId?: string;
22 /** Set for a proposal token: all it may do (`http/proposal_gate.ts`). */
23 readonly proposal?: Proposal;
2124 }
2225
2326 export interface Ctx {
src/http/mcp.ts +3 −1
@@ -85,6 +85,7 @@ import { getIssue, getIssueById, listOpenAgentPullsVisibleTo } from "../core/iss
8585 import { waitsForPerson } from "../crew/review.ts";
8686 import { attachLook } from "../actions/attach_look.ts";
8787 import { lookUrl } from "../core/looks.ts";
88 import { pullProposalOf } from "../core/proposal_token.ts";
8889 import { getPull, pullDir, pullMergeState } from "../core/pulls.ts";
8990 import { repoGraph } from "../core/dependencies.ts";
9091 import { pillarView } from "../core/pillars.ts";
@@ -467,7 +468,7 @@ const TOOLS: readonly Tool[] = [
467468 {
468469 name: "pull",
469470 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.",
471472 inputSchema: {
472473 type: "object",
473474 properties: {
@@ -498,6 +499,7 @@ const TOOLS: readonly Tool[] = [
498499 state: pull.mergedAt !== null ? "merged" : pull.state,
499500 base: pull.baseBranch,
500501 head: pull.headBranch,
502 proposal: pullProposalOf(ctx.app.db, pull.id) ?? null,
501503 check: { state: check.state, baseOid: check.run?.baseOid ?? "" },
502504 ...(state ?? {}),
503505 });
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 boundedBody in the handler is the real cap. Add a comment saying so, so that this check is never taken as the limit.

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";
419419 import { notFoundPage } from "../web/chrome.ts";
420420 import { targetForShortCode } from "../origins/oauth.ts";
421421 import { apiRoutes } from "./api.ts";
422 import { inNamespace } from "../core/proposal_token.ts";
423 import { proposalGate } from "./proposal_gate.ts";
422424 import { feedRoutes } from "./feed.ts";
423425 import type { Database } from "bun:sqlite";
424426 import { mcpRoutes } from "./mcp.ts";
@@ -522,6 +524,7 @@ async function resolveAuth(app: App, request: Request): Promise<Auth> {
522524 user: verified.value.user,
523525 scope: verified.value.scope,
524526 via: "token",
527 ...(verified.value.proposal ? { proposal: verified.value.proposal } : {}),
525528 };
526529 }
527530 return NO_AUTH;
@@ -544,6 +547,9 @@ async function resolveAuth(app: App, request: Request): Promise<Auth> {
544547 user: verified.value.user,
545548 scope: verified.value.scope,
546549 via: "token",
550 ...(verified.value.proposal
551 ? { proposal: verified.value.proposal }
552 : {}),
547553 };
548554 }
549555 return NO_AUTH;
@@ -951,6 +957,7 @@ export function buildRouter(): Router<Handler> {
951957 secret: ctx.app.hookSecret,
952958 repoId,
953959 actorId: ctx.auth.user?.id ?? "",
960 ...(ctx.auth.proposal ? { proposalChild: ctx.auth.proposal.child } : {}),
954961 }),
955962 ...cap,
956963 },
@@ -1648,6 +1655,18 @@ async function handleHook(app: App, request: Request): Promise<Response> {
16481655 ? getUserById(app.db, input.actorId)
16491656 : undefined;
16501657 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 }
16511670 // The pull namespace belongs to the forge, never to pushers.
16521671 if (update.ref.startsWith("refs/pull/")) {
16531672 return new Response(`${update.ref}: refs/pull/* is reserved`, {
@@ -2212,6 +2231,11 @@ async function answerGet(
22122231 noteCaller(request, app.db, auth.user.id, auth.sessionId);
22132232 }
22142233
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
22152239 // The two things standing between one runaway agent and everybody
22162240 // else's latency, applied before a handler runs and after auth is
22172241 // known — a limit needs an identity, and a replay needs a credential.
src/tui/screens/pull.ts +2 −1
@@ -45,6 +45,7 @@ import {
4545 appsRuntime,
4646 type EnvState,
4747 type EnvStatus, envsFor } from "../../apps/runtime.ts";
48 import { proposedByText } from "../../core/proposal_token.ts";
4849 import { getAppByRepo } from "../../core/apps.ts";
4950 import { getIssue, type Issue } from "../../core/issues.ts";
5051 import { pullMayRun } from "../../core/pull_trust.ts";
@@ -789,7 +790,7 @@ export function pullScreen(
789790 x1,
790791 head!.y + 1,
791792 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)}` : ""}`,
793794 Math.max(0, edge - x1),
794795 ),
795796 style(c.mutedFg, c.bg),
src/view/local.ts +2
@@ -8,6 +8,7 @@
88 * `canRead`; what is left is shape.
99 */
1010
11 import { pullProposalOf } from "../core/proposal_token.ts";
1112 import { mayView, sealedScope } from "../core/sealed.ts";
1213 import type { App } from "../app.ts";
1314 import {
@@ -306,6 +307,7 @@ export function localSource(
306307 diff,
307308 mergeable: status.mergeable,
308309 conflicts: status.conflicts,
310 proposal: pullProposalOf(app.db, row.id) ?? null,
309311 reviews: listReviews(app.db, row.id).map((review) => ({
310312 author: review.originAuthor ?? review.authorName,
311313 state: review.state,
src/view/origin.ts +1
@@ -404,6 +404,7 @@ function build(
404404 // in a form worth rendering. Null is the honest answer.
405405 mergeable: null,
406406 conflicts: [],
407 proposal: null,
407408 reviews: verdicts.ok
408409 ? verdicts.value.map((review) => ({
409410 author: review.author,
src/view/source.ts +2
@@ -181,6 +181,8 @@ export interface PullFacts {
181181 readonly conflicts: readonly string[];
182182 /** Every verdict, newest last. Empty when nobody has reviewed. */
183183 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;
184186 }
185187
186188 export interface IssueDetail {
src/web/studio/change.ts +9 −1
@@ -9,6 +9,7 @@
99 * bigger.
1010 */
1111
12 import { pullProposalOf } from "../../core/proposal_token.ts";
1213 import { getIssueById } from "../../core/issues.ts";
1314 import { mayEditIssue } from "../../actions/issues.ts";
1415 import { marksByName, type Mark } from "../../core/marks.ts";
@@ -211,7 +212,14 @@ export function changeHead(ctx: Ctx, repo: Repo, pull: Pull): Html {
211212 >${icon("external")}view on origin</a
212213 >`,
213214 )}
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 )}
215223 </div>
216224 </div>`;
217225 }
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("&lt;script&gt;x&lt;/script&gt;");
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs