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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/663-reopen-public-signup-the-edge-gate-comes → main 4 commits fork 1 thread · 1 unresolved
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 | ||
| 266 | 266 | `Release X is unproven: …` card on an admin's board and on the terminal's first |
| 267 | 267 | screen (the same cards), and is paged once; a later passing version clears it. |
| 268 | 268 | |
| 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 | ||
| 269 | 308 | What you asked for: `Ctrl+K`, then **Asked**, is `/asked` in the terminal — your |
| 270 | 309 | thoughts, then the issues you can write to that are not shipped, oldest first |
| 271 | 310 | (`j`/`k` move, `enter` gives the issue's web address, `r` refreshes). It is read |
scripts/smoke.ts +19 −2
| @@ -9,9 +9,15 @@ | ||
| 9 | 9 | * at most `--wait <seconds>` (default 120), and fails naming the last version |
| 10 | 10 | * seen. `/sw.js` must be a 200 JavaScript response whose navigation branch |
| 11 | 11 | * 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. | |
| 12 | 17 | */ |
| 13 | 18 | |
| 14 | 19 | import { smoke } from "../src/release/smoke.ts"; |
| 20 | import { outsider } from "../src/release/outsider.ts"; | |
| 15 | 21 | export * from "../src/release/smoke.ts"; |
| 16 | 22 | |
| 17 | 23 | if (import.meta.main) { |
| @@ -21,17 +27,28 @@ if (import.meta.main) { | ||
| 21 | 27 | if (i < 0) return undefined; |
| 22 | 28 | return args.splice(i, 2)[1]; |
| 23 | 29 | }; |
| 30 | const withOutsider = args.includes("--outsider"); | |
| 31 | if (withOutsider) args.splice(args.indexOf("--outsider"), 1); | |
| 24 | 32 | const version = flag("--version"); |
| 25 | 33 | const wait = flag("--wait"); |
| 26 | 34 | const base = args[0]; |
| 27 | 35 | const seconds = wait === undefined ? 120 : Number(wait); |
| 28 | 36 | 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"); | |
| 30 | 42 | process.exit(2); |
| 31 | 43 | } |
| 32 | 44 | const failures = await smoke(base, process.env["FORGE_TOKEN"] ?? "", { version, waitMs: seconds * 1000 }); |
| 33 | 45 | for (const f of failures) |
| 34 | 46 | 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)"); | |
| 36 | 53 | console.log(`smoke ok: ${base}`); |
| 37 | 54 | } |
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 | ||
| 150 | 150 | expect(isSignInRedirect(at, res(303, "https://other.example/_plat/login"))).toBe(false); |
| 151 | 151 | expect(isSignInRedirect(at, res(200, "/signin"))).toBe(false); |
| 152 | 152 | }); |
| 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 | }); |
new above
offline
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.