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

Reopen public signup: the edge gate comes down only when a fresh account is provably bounded #2858

Merged opened this in answer to

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

Merged

scout/forge:scout/663-reopen-public-signup-the-edge-gate-comes → main 4 commits fork 1 thread · 1 unresolved

  • Merged. scout/663-reopen-public-signup-the-edge-gate-comes was merged into main as b4c5c50.

Review

Sign in to review this change.
7 files changed +622 −2
README.md +39
@@ -266,6 +266,45 @@ keeps the verdict in `$FORGE_APP_DATA/release/proof.json`. A failure is a
266266 `Release X is unproven: …` card on an admin's board and on the terminal's first
267267 screen (the same cards), and is paged once; a later passing version clears it.
268268
269 **The outsider suite** (#663) is the proof a person reads before flipping signup
270 to open. It adds the proof only: it removes no gate and reopens nothing.
271 `signup-gate.yaml` and `outsider-gate.yaml` in `trevato-apps/gitops` stay up
272 until a person deletes them there.
273
274 It tries what a stranger must not do through all the doors a stranger has:
275
276 - **HTTP API and web form** (`src/release/outsider.ts`): an admin token, a
277 repository or app of their own, any read of the owner's private
278 repositories, and assigning a crew member on a real issue — the JSON
279 `PUT …/issues/:n/assignee` with a token, and the form `POST
280 /:owner/:name/issues/:n/assignee` with the cookie session a browser would
281 hold. A refusal is positively seen: a 403 with its error code, a 404 only for
282 private repositories, or a redirect whose landing page says so. The issue is
283 read back and its assignee and the repository's runs must not have moved,
284 and the owner's own call to the assignee route must answer 422 so a rename
285 turns the suite red.
286 - **MCP** (same module): `tell`, `make_it_yours`, `signup`, and reads of
287 private repositories through `repo`, `usage` and `apps`, each required to
288 answer as an error; `tools/list` must still name them.
289 - **Terminal** (`test/helpers/outsider-tty.ts`, run by
290 `test/outsider-tty.test.ts`): the stranger drives the terminal in process,
291 asks for a new repository, searches for a private one and opens the assign
292 picker on a public issue; the screen must show the refusal and nothing may
293 change. The terminal is a client of the forge's actions, not of HTTP, so it
294 cannot be driven against a live forge: it runs in the gate only.
295
296 `test/outsider-suite.test.ts` and `test/outsider-tty.test.ts` run in the gate
297 and each prove the suite goes red with a door left open.
298 `FORGE_TOKEN=… bun scripts/smoke.ts <url> --outsider` runs the HTTP and MCP
299 doors against a live forge. **It makes a real account on that forge**, so
300 point it only where that is wanted. Its result is valid for reopening signup
301 only together with a green gate run of the terminal test; HTTP and MCP green
302 alone do not cover the terminal door. Without `FORGE_TOKEN`, `--outsider`
303 exits 2 and the suite reports `owner token` as not tried, so a partial run is
304 never read as a pass. Spend and run bounds are proven in process only; this
305 pull adds the proof but does not reopen signup — the gitops gate stays for a
306 person.
307
269308 What you asked for: `Ctrl+K`, then **Asked**, is `/asked` in the terminal — your
270309 thoughts, then the issues you can write to that are not shipped, oldest first
271310 (`j`/`k` move, `enter` gives the issue's web address, `r` refreshes). It is read
scripts/smoke.ts +19 −2
@@ -9,9 +9,15 @@
99 * at most `--wait <seconds>` (default 120), and fails naming the last version
1010 * seen. `/sw.js` must be a 200 JavaScript response whose navigation branch
1111 * never answers with `Response.error()`.
12 *
13 * `--outsider` also signs up a fresh account and proves each thing a
14 * stranger must not do is refused over HTTP and MCP (`release/outsider.ts`,
15 * #663); the terminal door is proven by the gate. It makes one real account, so run it against a forge whose signup is open to it, with
16 * `FORGE_TOKEN` naming the private repositories to reach for.
1217 */
1318
1419 import { smoke } from "../src/release/smoke.ts";
20 import { outsider } from "../src/release/outsider.ts";
1521 export * from "../src/release/smoke.ts";
1622
1723 if (import.meta.main) {
@@ -21,17 +27,28 @@ if (import.meta.main) {
2127 if (i < 0) return undefined;
2228 return args.splice(i, 2)[1];
2329 };
30 const withOutsider = args.includes("--outsider");
31 if (withOutsider) args.splice(args.indexOf("--outsider"), 1);
2432 const version = flag("--version");
2533 const wait = flag("--wait");
2634 const base = args[0];
2735 const seconds = wait === undefined ? 120 : Number(wait);
2836 if (!base || !Number.isFinite(seconds) || seconds < 0 || args.length > 1) {
29 console.error("usage: bun scripts/smoke.ts <url> [--version <v>] [--wait <seconds>]");
37 console.error("usage: bun scripts/smoke.ts <url> [--version <v>] [--wait <seconds>] [--outsider]");
38 process.exit(2);
39 }
40 if (withOutsider && !process.env["FORGE_TOKEN"]) {
41 console.error("--outsider needs FORGE_TOKEN naming the owner's repos: private reads, steering, the form door and MCP tells cannot be tried without it");
3042 process.exit(2);
3143 }
3244 const failures = await smoke(base, process.env["FORGE_TOKEN"] ?? "", { version, waitMs: seconds * 1000 });
outdated

With no FORGE_TOKEN this passes {}, so the suite skips all the owner-dependent checks and still ends in the success line. Fail here instead.

3345 for (const f of failures)
3446 console.error(`FAIL ${f.url} agent=${f.agent} status=${f.status} content-type=${f.contentType || "-"} (${f.why})`);
35 if (failures.length) process.exit(1);
47 const unrefused = withOutsider
48 ? await outsider(base, { ...(process.env["FORGE_TOKEN"] ? { ownerToken: process.env["FORGE_TOKEN"] } : {}) })
49 : [];
50 for (const r of unrefused) console.error(`FAIL outsider: ${r.check} (${r.why})`);
51 if (failures.length || unrefused.length) process.exit(1);
52 if (withOutsider) console.log("outsider suite ok for HTTP and MCP; the terminal door is proven by the gate (test/outsider-tty.test.ts)");
3653 console.log(`smoke ok: ${base}`);
3754 }
src/release/outsider.ts added +223
@@ -0,0 +1,223 @@
1 /**
2 * The outsider suite, against a running forge (#663). It signs up a fresh
3 * account over HTTP and tries each thing a stranger must not do, through the
4 * JSON API, the web form (with the cookie session) and MCP; every one must be
5 * refused. The terminal door cannot be reached over HTTP: `test/outsider-tty.test.ts`.
6 * Signup stays shut at the edge until a person has read this suite's result
7 * green: `bun scripts/smoke.ts <url> --outsider`.
8 *
9 * With `ownerToken` it also reaches for the owner's own repositories —
10 * private ones must not exist to the stranger, and no repository's crew may
11 * take an assignment from it. It writes nothing but the one account
12 * (every other attempt is one that must be refused).
13 */
14
15 export interface Refusal {
16 check: string;
17 why: string;
18 }
19
20 export interface OutsiderOptions {
21 /** The owner's token: names the private repositories to reach for. Without it those checks are not tried, and the run reports that as a refusal not seen. */
22 ownerToken?: string;
23 fetch?: typeof fetch;
24 /** A name for the account; random when left out. */
25 username?: string;
26 }
27
28 type Json = Record<string, unknown>;
29
30 /** Runs the suite against `base`; returns what was not refused (empty is green). */
31 export async function outsider(base: string, options: OutsiderOptions = {}): Promise<Refusal[]> {
32 const root = new URL(base);
33 if (root.protocol !== "http:" && root.protocol !== "https:") throw new Error("url must be http(s)");
34 const doFetch = options.fetch ?? globalThis.fetch;
35 const bad: Refusal[] = [];
36 const call = async (path: string, token: string, method = "GET", json?: unknown): Promise<{ status: number; body: Json }> => {
37 const res = await doFetch(new URL(path, root).href, {
38 method,
39 redirect: "manual",
40 signal: AbortSignal.timeout(10_000),
41 headers: {
42 "content-type": "application/json",
43 ...(token ? { authorization: `Bearer ${token}` } : {}),
44 },
45 ...(json === undefined ? {} : { body: JSON.stringify(json) }),
46 });
47 const text = await res.text();
48 let body: Json = {};
49 try {
50 const parsed: unknown = JSON.parse(text);
51 if (parsed !== null && typeof parsed === "object") body = parsed as Json;
52 } catch {}
53 return { status: res.status, body };
54 };
55 /**
56 * The call must be refused as a refusal: one of `statuses` and, when
57 * given, that `error` code. A 404 or 405 from a route that is not there
58 * is never a refusal; 404 is accepted only where hiding is the intent.
59 */
60 const refused = async (
61 check: string,
62 path: string,
63 token: string,
64 want: { statuses: number[]; error?: string[]; method?: string; json?: unknown },
65 ) => {
66 const method = want.method ?? "GET";
67 const got = await call(path, token, method, want.json);
68 const code = String(got.body["error"] ?? "");
69 if (!want.statuses.includes(got.status) || (want.error !== undefined && !want.error.includes(code)))
70 bad.push({ check, why: `${method} ${path} answered ${got.status} ${code}, wanted ${want.statuses.join("/")}${want.error ? ` ${want.error.join("/")}` : ""}` });
71 return got;
72 };
73
74 const username = options.username ?? `outsider-${crypto.randomUUID().slice(0, 8)}`;
75 const password = `${crypto.randomUUID()}-${crypto.randomUUID()}`;
76 const signed = await call("/api/v1/users", "", "POST", { username, email: `${username}@example.com`, password });
77 if (signed.status !== 201) {
78 return [{ check: "sign up", why: `POST /api/v1/users answered ${signed.status} ${String(signed.body["error"] ?? "")}; the suite needs a fresh account` }];
79 }
80 if (signed.body["isAdmin"] === true || signed.body["is_admin"] === true)
81 bad.push({ check: "sign up", why: "a fresh account is an admin" });
82
83 const minted = await call("/api/v1/tokens", "", "POST", { username, password, scope: "rw", name: "outsider-suite" });
84 const token = typeof minted.body["token"] === "string" ? minted.body["token"] : "";
85 if (!token) return [...bad, { check: "sign in", why: `the fresh account got no token (${minted.status})` }];
86
87 const session = await doFetch(new URL("/api/v1/sessions", root).href, {
88 method: "POST",
89 signal: AbortSignal.timeout(10_000),
90 headers: { "content-type": "application/json" },
91 body: JSON.stringify({ username, password }),
92 });
93 await session.body?.cancel();
94 const cookies = { cookie: session.headers.getSetCookie().map((c) => c.split(";")[0]!).join("; ") };
95 if (!cookies.cookie.includes("forge_session=")) bad.push({ check: "sign in", why: `the fresh account got no session (${session.status})` });
96
97 // One MCP tools/call as the stranger: an error answer is a refusal.
98 const mcp = async (tool: string, args: Json, as = token): Promise<{ error: boolean; text: string }> => {
99 const res = await doFetch(new URL("/mcp", root).href, {
100 method: "POST",
101 redirect: "manual",
102 signal: AbortSignal.timeout(10_000),
103 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${as}` },
104 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: tool, arguments: args } }),
105 });
106 const text = await res.text();
107 try {
108 const result = (JSON.parse(text) as { result?: { isError?: boolean; content?: { text?: string }[] } }).result;
109 const said = result?.content?.[0]?.text ?? "";
110 // A tool that does not exist refuses everything; that is a moved door, not a closed one.
111 return { error: res.status === 200 && result?.isError === true && /^(not_member|not.a.member|unverified|forbidden|not_found|not_admin|admin_only|unauthorized)\b/i.test(said), text: said };
112 } catch {
113 return { error: false, text };
114 }
115 };
116 const toolList = await doFetch(new URL("/mcp", root).href, {
117 method: "POST",
118 signal: AbortSignal.timeout(10_000),
119 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` },
120 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
121 });
122 const toolsText = await toolList.text();
123 for (const tool of ["tell", "make_it_yours", "repo", "usage", "apps", "signup"])
124 if (!toolsText.includes(`"${tool}"`)) bad.push({ check: "MCP tools", why: `tools/list does not name ${tool}; the suite's doors may have moved` });
125
126 // No wider token than its own, and no made things without membership.
127 await refused("no admin token", "/api/v1/tokens", "", { method: "POST", statuses: [403], error: ["forbidden"], json: { username, password, scope: "admin", name: "x" } });
128 await refused("no repository of its own", "/api/v1/repos", token, { method: "POST", statuses: [403], error: ["unverified", "not_member"], json: { name: `${username}-repo` } });
129 await refused("no app of its own", "/api/v1/apps", token, { method: "POST", statuses: [403], error: ["unverified", "not_member"], json: { name: `${username}-app` } });
130
131 const opened = await mcp("signup", { mode: "open", dryRun: false });
132 if (!opened.error) bad.push({ check: "no admin tool (MCP signup)", why: `signup answered a stranger: ${opened.text.slice(0, 80)}` });
133
134 if (options.ownerToken) {
135 const owned = options.ownerToken;
136 const listed = await call("/api/v1/repos?limit=100", owned);
137 const items = Array.isArray(listed.body["items"]) ? (listed.body["items"] as Json[]) : [];
138 if (listed.status !== 200) bad.push({ check: "owner token", why: `GET /api/v1/repos answered ${listed.status} for the owner's token` });
139 let aimed = 0;
140 for (const repo of items.slice(0, 50)) {
141 const owner = String(repo["owner"] ?? repo["ownerName"] ?? "");
142 const name = String(repo["name"] ?? "");
143 if (!owner || !name) continue;
144 const at = `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`;
145 const label = `${owner}/${name}`;
146 const hidden = repo["visibility"] === "private";
147 if (hidden) {
148 for (const sub of ["", "/issues", "/usage", "/runs", "/crew"])
149 await refused(`no read of private ${label}`, at + sub, token, { statuses: [404] });
150 for (const tool of ["repo", "usage"]) {
151 const got = await mcp(tool, { repo: label });
152 if (!got.error) bad.push({ check: `no read of private ${label} (MCP ${tool})`, why: `${tool} answered a stranger: ${got.text.slice(0, 80)}` });
153 }
154 const listing = await mcp("apps", {});
155 if (listing.text.includes(`${label}`)) bad.push({ check: `no read of private ${label} (MCP apps)`, why: "apps lists it for a stranger" });
156 await refused(`no issue in private ${label}`, `${at}/issues`, token, { method: "POST", statuses: [404], json: { title: "outsider", body: "" } });
157 }
158 // Steering: an assignment would start a crew run. Aim at a real issue
159 // and a real crew member, through the JSON door and the form door, and
160 // read the issue back as the owner: it must not have moved.
161 const mine = await mcp("make_it_yours", { repo: label, dryRun: false });
162 if (!mine.error) bad.push({ check: `no copy of ${label} (MCP make_it_yours)`, why: `make_it_yours answered a stranger: ${mine.text.slice(0, 80)}` });
163 const issues = await call(`${at}/issues?limit=1`, owned);
164 const issue = Array.isArray(issues.body["items"]) ? (issues.body["items"] as Json[])[0] : undefined;
165 const crew = await call(`${at}/crew`, owned);
166 const agent = Array.isArray(crew.body["items"]) ? (crew.body["items"] as Json[])[0] : undefined;
167 const number = issue?.["number"];
168 const agentName = String(agent?.["username"] ?? agent?.["name"] ?? "");
169 if (typeof number !== "number" || !agentName) continue;
170 aimed++;
171 const door = `${at}/issues/${number}/assignee`;
172 // The route exists and checks permission before it checks the name: the owner meets 422.
173 const control = await call(door, owned, "PUT", { assignee: "no-such-account-outsider" });
174 if (control.status !== 422) bad.push({ check: "assignee route", why: `PUT ${door} answered ${control.status} for the owner, wanted 422; the route may have moved` });
175 const before = issue?.["assigneeName"] ?? null;
176 const runs = await call(`${at}/runs?limit=100`, owned);
177 const runsBefore = Array.isArray(runs.body["items"]) ? (runs.body["items"] as unknown[]).length : -1;
178 const words = hidden ? [404] : [403];
179 await refused(`no steering of ${label} (API)`, door, token, { method: "PUT", statuses: words, json: { assignee: agentName } });
180 // The form door, as a stranger at a browser: the cookie session, a same-origin POST.
181 const formAt = `/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/issues/${number}/assignee`;
182 const form = await doFetch(new URL(formAt, root).href, {
183 method: "POST",
184 redirect: "manual",
185 signal: AbortSignal.timeout(10_000),
186 headers: { cookie: cookies.cookie, "content-type": "application/x-www-form-urlencoded", origin: root.origin },
187 body: new URLSearchParams({ assignee: agentName }),
188 });
189 await form.body?.cancel();
190 const refusedPlainly = form.status === 403 || form.status === 404;
191 if (!refusedPlainly) {
192 // A redirect is a refusal only when the page it lands on says so.
193 const landed = form.status >= 300 && form.status < 400 ? form.headers.get("location") : null;
194 let said = "";
195 if (landed) {
196 const jar = [cookies.cookie, ...form.headers.getSetCookie().map((c) => c.split(";")[0]!)].join("; ");
197 const page = await doFetch(new URL(landed, root).href, { redirect: "manual", signal: AbortSignal.timeout(10_000), headers: { cookie: jar } });
198 said = await page.text();
199 }
200 if (!/need write access|permission|not allowed|forbidden/i.test(said))
201 bad.push({ check: `no steering of ${label} (form)`, why: `the assignee form answered ${form.status} and no refusal was seen` });
202 }
203 if (hidden && form.status !== 404 && form.status !== 403)
204 bad.push({ check: `no steering of ${label} (form)`, why: `a private repository's form answered ${form.status}, wanted 404` });
205 // The MCP door: the tools that tell the crew or copy an app.
206 const told = await mcp("tell", { repo: label, text: "outsider suite: please change something" });
207 if (!told.error) bad.push({ check: `no steering of ${label} (MCP tell)`, why: "tell was accepted from a stranger" });
208 const after = await call(`${at}/issues?limit=1`, owned);
209 const runsNow = await call(`${at}/runs?limit=100`, owned);
210 if (runsNow.status === 200 && Array.isArray(runsNow.body["items"]) && (runsNow.body["items"] as unknown[]).length !== runsBefore)
211 bad.push({ check: `no steering of ${label}`, why: "a run appeared after the stranger's attempts" });
212 const now = Array.isArray(after.body["items"]) ? (after.body["items"] as Json[])[0]?.["assigneeName"] ?? null : "unreadable";
213 if (now !== before) bad.push({ check: `no steering of ${label}`, why: `the issue's assignee went from ${String(before)} to ${String(now)} by the stranger's hand` });
214 }
215 const kept = await call(`/api/v1/repos?limit=100`, token);
216 if (Array.isArray(kept.body["items"]) && (kept.body["items"] as Json[]).some((r) => String(r["ownerName"] ?? r["owner"]) === username))
217 bad.push({ check: "no repository of its own", why: "the stranger owns a repository after the attempts" });
218 if (aimed === 0) bad.push({ check: "no steering", why: "no repository of the owner has both an issue and a crew member to aim at" });
219 } else {
220 bad.push({ check: "owner token", why: "steering and private reads were not tried" });
221 }
222 return bad;
223 }
test/helpers/outsider-tty.ts added +101
@@ -0,0 +1,101 @@
1 /**
2 * The outsider suite's terminal door (#663): a stranger drives the terminal
3 * in process, the way `test/tui-quotas.test.ts` does, and each forbidden
4 * thing must be refused where the screen says so. The terminal is a client
5 * of the forge's actions, so it cannot be reached over HTTP: this runs in
6 * the gate, not against a live forge.
7 */
8
9 import type { App as Forge } from "../../src/app.ts";
10 import type { Refusal } from "../../src/release/outsider.ts";
11 import { getRepo } from "../../src/core/repos.ts";
12 import { getIssue } from "../../src/core/issues.ts";
13 import { NOT_A_MEMBER } from "../../src/core/roles.ts";
14 import type { User } from "../../src/core/users.ts";
15 import { createForgeApp } from "../../src/tui/forge.ts";
16 import { assignPicker } from "../../src/tui/screens/assign.ts";
17 import type { Screen } from "../../src/tui/app/app.ts";
18 import { Session } from "../../src/tui/session.ts";
19 import { headlessTerminal } from "../../src/tui/term/terminal.ts";
20 import { settleApp } from "./tui.ts";
21
22 /** What a refused toast reads like on the screen. */
23 const REFUSAL = new RegExp(`${NOT_A_MEMBER}|unverified|do not have permission|need write access|not allowed`, "i");
24
25 export interface Target {
26 owner: string;
27 name: string;
28 /** An issue number in it. */
29 issue: number;
30 /** A crew member's username to assign. */
31 agent: string;
32 hidden: boolean;
33 }
34
35 export async function outsiderTty(forge: Forge, stranger: User, targets: readonly Target[]): Promise<Refusal[]> {
36 const bad: Refusal[] = [];
37 const terminal = headlessTerminal(140, 32);
38 const identity = { sshUsername: "t", remoteAddress: "127.0.0.1", user: stranger };
39 const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false });
40 app.start();
41 const press = async (...keys: string[]) => {
42 for (const key of keys) app.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false });
43 await settleApp(app);
44 };
45 const ctrl = async (key: string) => {
46 app.dispatch({ type: "key", key, ctrl: true, alt: false, shift: false });
47 await settleApp(app);
48 };
49 const type = async (text: string) => {
50 for (const ch of text) app.dispatch({ type: "key", key: ch, ctrl: false, alt: false, shift: false });
51 await settleApp(app);
52 };
53 const seen = () => app.screen.toText();
54 try {
55 // A repository of its own, from the palette.
56 const mine = `${stranger.username}-tty`;
57 await ctrl("k");
58 await type("New repository");
59 await press("enter");
60 await type(mine);
61 await ctrl("d");
62 if (getRepo(forge.db, stranger.username, mine) !== undefined)
63 bad.push({ check: "no repository of its own (terminal)", why: "the terminal made a repository for a stranger" });
64 else if (!REFUSAL.test(seen()))
65 bad.push({ check: "no repository of its own (terminal)", why: `no refusal on the screen:\n${seen()}` });
66 await press("esc");
67 await press("esc");
68
69 for (const t of targets) {
70 const label = `${t.owner}/${t.name}`;
71 const repo = getRepo(forge.db, t.owner, t.name);
72 const issue = repo ? getIssue(forge.db, repo.id, t.issue) : undefined;
73 if (!repo || !issue) continue;
74 if (t.hidden) {
75 // A private repository is not on offer: the palette's search never names it.
76 await ctrl("k");
77 await type(t.name);
78 if (seen().includes(label)) bad.push({ check: `no read of private ${label} (terminal)`, why: "the palette names it for a stranger" });
79 await press("esc");
80 continue;
81 }
82 // Assign a crew member to a public repository's issue: the picker is the terminal's door.
83 const before = issue.assigneeId;
84 const parent: Screen = { title: "outsider", keys: [], render() {}, key: () => false };
85 assignPicker(new Session(forge, identity), app.ctx, parent, repo, issue, () => {});
86 await settleApp(app);
87 await press("/");
88 await type(t.agent);
89 await press("enter", "enter");
90 const after = getIssue(forge.db, repo.id, t.issue);
91 if (after?.assigneeId !== before)
92 bad.push({ check: `no steering of ${label} (terminal)`, why: "the terminal assigned a crew member for a stranger" });
93 else if (!REFUSAL.test(seen()))
94 bad.push({ check: `no steering of ${label} (terminal)`, why: `no refusal on the screen:\n${seen()}` });
95 await press("esc");
96 }
97 } finally {
98 app.stop();
99 }
100 return bad;
101 }
test/outsider-suite.test.ts added +151
@@ -0,0 +1,151 @@
1 /**
2 * The outsider suite (#663) against a running forge: a fresh account signs
3 * up over HTTP and every forbidden thing is refused. The suite itself is
4 * proven here — green on this forge, red when a door is left open.
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 { createApp, type App } from "../src/app.ts";
12 import { addToCrew, createAgent } from "../src/actions/crew.ts";
13 import { openIssue } from "../src/actions/issues.ts";
14 import { createRepo } from "../src/actions/repos.ts";
15 import type { Repo } from "../src/core/repos.ts";
16 import { grantLeft } from "../src/core/model_grant.ts";
17 import { listRuns } from "../src/core/runs.ts";
18 import { createToken } from "../src/core/tokens.ts";
19 import { getUserByName } from "../src/core/users.ts";
20 import { setLimit } from "../src/settings/limits.ts";
21 import { setSignup } from "../src/settings/signup.ts";
22 import { outsider } from "../src/release/outsider.ts";
23 import { serve, type Servers } from "../src/http/server.ts";
24 import { seedAdmin } from "./helpers/admin.ts";
25 import { quietLogs } from "./helpers/globals.ts";
26
27 quietLogs();
28
29 describe("outsider suite", () => {
30 let dataDir = "";
31 let app: App;
32 let servers: Servers;
33 let ownerToken = "";
34 let vault: Repo;
35
36 beforeAll(async () => {
37 dataDir = await mkdtemp(join(tmpdir(), "forge-outsider-suite-"));
38 app = createApp({ dataDir, port: 0 });
39 servers = await serve(app);
40 const seeded = await seedAdmin(app, { username: "rowan", email: "[email protected]", password: "a-long-owner-passphrase" });
41 if (!seeded.ok) throw new Error(String(seeded.error));
42 const owner = seeded.value;
43 // Each case signs one account up from this one source.
44 setLimit(app.db, "signup.per_source", 50, owner.id);
45 ownerToken = createToken(app.db, owner.id, "t", "rw").token;
46 const agent = await createAgent(app, owner, { username: "helper" });
47 if (!agent.ok) throw new Error(String(agent.error));
48 for (const [name, visibility] of [["vault", "private"], ["lawn", "public"]] as const) {
49 const made = await createRepo(app, owner, { name, visibility });
50 if (!made.ok) throw new Error(String(made.error));
51 if (name === "vault") vault = made.value;
52 const opened = openIssue(app, owner, made.value, { title: "weed", body: "" });
53 if (!opened.ok) throw new Error(String(opened.error));
54 const crewed = addToCrew(app, owner, made.value, "helper");
55 if (!crewed.ok) throw new Error(String(crewed.error));
56 }
57 });
58
59 afterAll(async () => {
60 await servers.stop();
61 app.close();
62 await rm(dataDir, { recursive: true, force: true });
63 });
64
65 test("a fresh account is refused every forbidden thing, and spends nothing", async () => {
66 expect(await outsider(servers.url, { ownerToken, username: "stranger" })).toEqual([]);
67 const stranger = getUserByName(app.db, "stranger")!;
68 expect(stranger.isAdmin).toBe(false);
69 expect(grantLeft(app.db, stranger.id, new Date())).toBeUndefined();
70 expect(listRuns(app.db, vault.id, { kind: "agent", limit: 10 }).length).toBe(0);
71 });
72
73 test("it reports a door left open", async () => {
74 const open = (async (input: Request | string | URL, init?: RequestInit) => {
75 const url = new URL(input instanceof Request ? input.url : String(input));
76 if (url.pathname === "/api/v1/repos" && init?.method === "POST")
77 return new Response("{}", { status: 201 });
78 return fetch(input, init);
79 }) as typeof fetch;
80 const got = await outsider(servers.url, { ownerToken, fetch: open, username: "stranger2" });
81 expect(got.map((r) => r.check)).toEqual(["no repository of its own"]);
82 });
83
84 test("it reports a crew assignment the stranger can make, through the API door or the form door", async () => {
85 const asOwner = (init?: RequestInit): RequestInit => ({
86 ...init,
87 headers: { ...(init?.headers as Record<string, string>), cookie: "", authorization: `Bearer ${ownerToken}` },
88 });
89 for (const [door, method] of [["/api/v1/repos/rowan/lawn/issues/1/assignee", "PUT"], ["/rowan/lawn/issues/1/assignee", "POST"]] as const) {
90 const open = (async (input: Request | string | URL, init?: RequestInit) => {
91 const url = new URL(input instanceof Request ? input.url : String(input));
92 const body = String(init?.body ?? "");
93 return fetch(input, url.pathname === door && init?.method === method && body.includes("helper") ? asOwner(init) : init);
94 }) as typeof fetch;
95 const got = await outsider(servers.url, { ownerToken, fetch: open, username: `stranger-${method.toLowerCase()}` });
96 expect(got.map((r) => r.check)).toContain("no steering of rowan/lawn");
97 app.db.query("UPDATE issue SET assignee_id = NULL").run();
98 }
99 });
100
101 test("it reports an MCP door left open, tell or make_it_yours", async () => {
102 for (const tool of ["tell", "make_it_yours"]) {
103 const open = (async (input: Request | string | URL, init?: RequestInit) => {
104 if (new URL(input instanceof Request ? input.url : String(input)).pathname === "/mcp" && String(init?.body).includes(`"name":"${tool}"`))
105 return Response.json({ jsonrpc: "2.0", id: 1, result: { content: [{ type: "text", text: "{}" }] } });
106 return fetch(input, init);
107 }) as typeof fetch;
108 const got = await outsider(servers.url, { ownerToken, fetch: open, username: `stranger-mcp-${tool}` });
109 expect(got.some((r) => r.check.includes(`MCP ${tool}`))).toBe(true);
110 }
111 });
112
113 test("a form that answers 200 and does nothing is not a refusal", async () => {
114 const quiet = (async (input: Request | string | URL, init?: RequestInit) => {
115 const url = new URL(input instanceof Request ? input.url : String(input));
116 return url.pathname === "/rowan/lawn/issues/1/assignee" && init?.method === "POST" ? new Response("ok", { status: 200 }) : fetch(input, init);
117 }) as typeof fetch;
118 const got = await outsider(servers.url, { ownerToken, fetch: quiet, username: "stranger-quiet" });
119 expect(got.map((r) => r.check)).toEqual(["no steering of rowan/lawn (form)"]);
120 });
121
122 test("it is red when a route has moved, not green", async () => {
123 const moved = (async (input: Request | string | URL, init?: RequestInit) => {
124 const url = new URL(input instanceof Request ? input.url : String(input));
125 return url.pathname.endsWith("/assignee") && init?.method === "PUT" ? new Response("{}", { status: 404 }) : fetch(input, init);
126 }) as typeof fetch;
127 const got = await outsider(servers.url, { ownerToken, fetch: moved, username: "stranger5" });
128 expect(got.map((r) => r.check)).toContain("assignee route");
129 });
130
131 test("it reports a private repository the stranger can read", async () => {
132 const leak = (async (input: Request | string | URL, init?: RequestInit) => {
133 const url = new URL(input instanceof Request ? input.url : String(input));
134 if (url.pathname === "/api/v1/repos/rowan/vault") return new Response("{}", { status: 200 });
135 return fetch(input, init);
136 }) as typeof fetch;
137 const got = await outsider(servers.url, { ownerToken, fetch: leak, username: "stranger4" });
138 expect(got.map((r) => r.check)).toEqual(["no read of private rowan/vault"]);
139 });
140
141 test("without an owner token it reports the checks it could not try", async () => {
142 const got = await outsider(servers.url, { username: "stranger-notoken" });
143 expect(got.map((r) => r.check)).toContain("owner token");
144 });
145
146 test("it says so when no account can be made", async () => {
147 setSignup(app.db, "closed", "test");
148 const got = await outsider(servers.url, { username: "stranger3" });
149 expect(got[0]?.check).toBe("sign up");
150 });
151 });
test/outsider-tty.test.ts added +79
@@ -0,0 +1,79 @@
1 /**
2 * The outsider suite's terminal door (#663): a stranger in the terminal is
3 * refused a repository of their own, a view of a private repository and a
4 * crew assignment, and the suite goes red when the assignment is left open.
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 { createApp, type App } from "../src/app.ts";
12 import { addToCrew, createAgent } from "../src/actions/crew.ts";
13 import { openIssue } from "../src/actions/issues.ts";
14 import { createRepo } from "../src/actions/repos.ts";
15 import { getIssue } from "../src/core/issues.ts";
16 import { getRepo } from "../src/core/repos.ts";
17 import { setMember } from "../src/core/roles.ts";
18 import { createUser, getUserByName, type User } from "../src/core/users.ts";
19 import { seedAdmin } from "./helpers/admin.ts";
20 import { quietLogs } from "./helpers/globals.ts";
21 import { outsiderTty, type Target } from "./helpers/outsider-tty.ts";
22
23 quietLogs();
24
25 describe("outsider suite, terminal door", () => {
26 let dataDir = "";
27 let app: App;
28 let stranger: User;
29 const targets: Target[] = [];
30
31 beforeAll(async () => {
32 dataDir = await mkdtemp(join(tmpdir(), "forge-outsider-tty-"));
33 app = createApp({ dataDir, port: 0 });
34 const seeded = await seedAdmin(app, { username: "rowan", email: "[email protected]", password: "a-long-owner-passphrase" });
35 if (!seeded.ok) throw new Error(String(seeded.error));
36 const owner = seeded.value;
37 const agent = await createAgent(app, owner, { username: "helper" });
38 if (!agent.ok) throw new Error(String(agent.error));
39 for (const [name, visibility] of [["vault", "private"], ["lawn", "public"]] as const) {
40 const made = await createRepo(app, owner, { name, visibility });
41 if (!made.ok) throw new Error(String(made.error));
42 const opened = openIssue(app, owner, made.value, { title: "weed", body: "" });
43 if (!opened.ok) throw new Error(String(opened.error));
44 const crewed = addToCrew(app, owner, made.value, "helper");
45 if (!crewed.ok) throw new Error(String(crewed.error));
46 targets.push({ owner: "rowan", name, issue: opened.value.number, agent: "helper", hidden: visibility === "private" });
47 }
48 const made = await createUser(app.db, { origin: "seed", trusted: true, username: "stranger", email: "[email protected]", password: "a-long-stranger-passphrase" });
49 if (!made.ok) throw new Error(String(made.error));
50 stranger = made.value;
51 }, 60_000);
52
53 afterAll(async () => {
54 app.close();
55 await rm(dataDir, { recursive: true, force: true });
56 });
57
58 test("a stranger in the terminal is refused everything, and nothing changes", async () => {
59 expect(await outsiderTty(app, stranger, targets)).toEqual([]);
60 expect(getRepo(app.db, "stranger", "stranger-tty")).toBeUndefined();
61 const lawn = getRepo(app.db, "rowan", "lawn")!;
62 expect(getIssue(app.db, lawn.id, targets[1]!.issue)?.assigneeId).toBeNull();
63 }, 60_000);
64
65 test("it is red when the terminal lets a stranger make a repository and assign", async () => {
66 // The door left open: the stranger is a member and owns a public repository with a crew.
67 const owner = getUserByName(app.db, "rowan")!;
68 const made = await createRepo(app, owner, { name: "yard", visibility: "public" });
69 if (!made.ok) throw new Error(String(made.error));
70 const opened = openIssue(app, owner, made.value, { title: "weed", body: "" });
71 if (!opened.ok) throw new Error(String(opened.error));
72 const crewed = addToCrew(app, owner, made.value, "helper");
73 if (!crewed.ok) throw new Error(String(crewed.error));
74 app.db.query("UPDATE repo SET owner_id = ?1 WHERE id = ?2").run(stranger.id, made.value.id);
75 setMember(app.db, stranger.id, true, owner.id, new Date());
76 const got = await outsiderTty(app, stranger, [{ owner: "stranger", name: "yard", issue: opened.value.number, agent: "helper", hidden: false }]);
77 expect(got.map((r) => r.check)).toEqual(["no repository of its own (terminal)", "no steering of stranger/yard (terminal)"]);
78 }, 60_000);
79 });
test/smoke.test.ts +10
@@ -150,3 +150,13 @@ test("isSignInRedirect wants a redirect status and a same-origin sign-in Locatio
150150 expect(isSignInRedirect(at, res(303, "https://other.example/_plat/login"))).toBe(false);
151151 expect(isSignInRedirect(at, res(200, "/signin"))).toBe(false);
152152 });
153
154 test("--outsider without FORGE_TOKEN exits 2 and prints no ok", async () => {
155 const base = stub("text/css");
156 const env = { ...process.env };
157 delete env["FORGE_TOKEN"];
158 const proc = Bun.spawn(["bun", "scripts/smoke.ts", base, "--outsider"], { env, stdout: "pipe", stderr: "pipe" });
159 expect(await proc.exited).toBe(2);
160 expect(await new Response(proc.stdout).text()).not.toContain("ok");
161 expect(await new Response(proc.stderr).text()).toContain("FORGE_TOKEN");
162 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs