Public: anyone can read this; the crew can change it.
One test walks a stranger from sign-up to a planned forge through the API alone, naming the step that broke #3098
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3096-one-test-walks-a-stranger-from-sign-up-t → main 2 commits fork
3 files
changed +87
−1
docs/SPAWN.md +1 −1
| @@ -70,7 +70,7 @@ Children report in; the parent never reaches in. Every ten minutes a spawned chi | ||
| 70 | 70 | |
| 71 | 71 | ## Callers |
| 72 | 72 | |
| 73 | `POST /api/v1/forges {name, apps[], crew[], direction?, keepAccess?, modelKey?, budgetUsd?, dryRun}` (rw token, admin only) and the MCP `spawn` tool share `src/spawn/door.ts`. `dryRun` is true unless it is `false`: the answer is the plan and what is left behind, and nothing is made. `GET /api/v1/forges` lists the ledger's children. The child's org is its name; `crew` names the agents whose briefs travel. Errors: 401 unauthorized, 403 forbidden / cross_origin, 422 invalid, 409 refused / exists, 500 failed — each with one sentence. The MCP lets an `ro` token plan, never make. | |
| 73 | `POST /api/v1/forges {name, apps[], crew[], direction?, keepAccess?, modelKey?, budgetUsd?, dryRun}` (rw token, admin only) and the MCP `spawn` tool share `src/spawn/door.ts`. `dryRun` is true unless it is `false`: the answer is the plan, what is left behind, and `firstAdmin` (the caller's username, or `null` for an anonymous plan, where whoever claims the child becomes its admin), and nothing is made. `GET /api/v1/forges` lists the ledger's children. The child's org is its name; `crew` names the agents whose briefs travel. Errors: 401 unauthorized, 403 forbidden / cross_origin, 422 invalid, 409 refused / exists, 500 failed — each with one sentence. The MCP lets an `ro` token plan, never make. | |
| 74 | 74 | |
| 75 | 75 | ## The forge's own code, and the chain (#1085) |
| 76 | 76 |
src/spawn/door.ts +2
| @@ -268,6 +268,8 @@ export async function spawnDoor( | ||
| 268 | 268 | body: { |
| 269 | 269 | dryRun: true, |
| 270 | 270 | plan: planned.value, |
| 271 | // Who the child's first admin will be: the caller, or whoever claims it. | |
| 272 | firstAdmin: user?.username ?? null, | |
| 271 | 273 | ...dryRun(planned.value, source), |
| 272 | 274 | ...(planned.value.budgetUsd === null ? {} : { keyNote: KEY_BUDGET_NOTE }), |
| 273 | 275 | }, |
test/stranger-to-forge.test.ts added +84
| @@ -0,0 +1,84 @@ | ||
| 1 | /** | |
| 2 | * A stranger to a planned forge through the API alone (#3096, part of #2914): | |
| 3 | * sign up with a username and password, sign in, take a token, and plan a | |
| 4 | * forge. No socket, each request handed to forgeHttp(app).handle. Each step | |
| 5 | * is its own test, so a failure names the step that broke. | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, 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 { forgeHttp, type ForgeHttp } from "../src/http/server.ts"; | |
| 13 | import { quietLogs } from "./helpers/globals.ts"; | |
| 14 | ||
| 15 | quietLogs(); | |
| 16 | ||
| 17 | const ORIGIN = "http://forge.test"; | |
| 18 | const USERNAME = "stranger"; | |
| 19 | const PASSWORD = "a-long-passphrase"; | |
| 20 | ||
| 21 | let dir = ""; | |
| 22 | let app: App; | |
| 23 | let http: ForgeHttp; | |
| 24 | let cookie = ""; | |
| 25 | let token = ""; | |
| 26 | ||
| 27 | const call = (path: string, body: unknown, headers: Record<string, string> = {}) => | |
| 28 | http.handle( | |
| 29 | new Request(`${ORIGIN}${path}`, { | |
| 30 | method: "POST", | |
| 31 | headers: { "content-type": "application/json", origin: ORIGIN, ...headers }, | |
| 32 | body: JSON.stringify(body), | |
| 33 | }), | |
| 34 | ); | |
| 35 | ||
| 36 | beforeAll(async () => { | |
| 37 | dir = await mkdtemp(join(tmpdir(), "forge-stranger-")); | |
| 38 | delete process.env.FORGE_EDGE_TOKEN; | |
| 39 | app = createApp({ dataDir: dir, port: 0 }); | |
| 40 | http = forgeHttp(app); | |
| 41 | }); | |
| 42 | ||
| 43 | afterAll(async () => { | |
| 44 | http.close(); | |
| 45 | app.close(); | |
| 46 | await rm(dir, { recursive: true, force: true }); | |
| 47 | }); | |
| 48 | ||
| 49 | test("step 1, sign-up: a username and a password, no email, makes an account", async () => { | |
| 50 | const res = await call("/api/v1/users", { username: USERNAME, password: PASSWORD }); | |
| 51 | expect(res.status).toBe(201); | |
| 52 | }); | |
| 53 | ||
| 54 | test("step 2, sign-in: the same credentials open a session", async () => { | |
| 55 | const res = await call("/api/v1/sessions", { username: USERNAME, password: PASSWORD }); | |
| 56 | expect(res.status).toBe(201); | |
| 57 | cookie = (res.headers.get("set-cookie") ?? "").split(";")[0] ?? ""; | |
| 58 | expect(cookie).not.toBe(""); | |
| 59 | }); | |
| 60 | ||
| 61 | test("step 3, token: the session mints an rw token", async () => { | |
| 62 | const res = await call("/api/v1/tokens", { scope: "rw", name: "stranger-to-forge" }, { cookie }); | |
| 63 | expect(res.status).toBe(201); | |
| 64 | token = ((await res.json()) as { token?: string }).token ?? ""; | |
| 65 | expect(token).not.toBe(""); | |
| 66 | }); | |
| 67 | ||
| 68 | test("step 4, plan: a dry-run POST /api/v1/forges names the owner as first admin", async () => { | |
| 69 | const res = await call( | |
| 70 | "/api/v1/forges", | |
| 71 | { name: "strangers-forge", dryRun: true }, | |
| 72 | { authorization: `Bearer ${token}` }, | |
| 73 | ); | |
| 74 | expect(res.status).toBe(200); | |
| 75 | const plan = (await res.json()) as { dryRun?: boolean; firstAdmin?: string | null }; | |
| 76 | expect(plan.dryRun).toBe(true); | |
| 77 | expect(plan.firstAdmin).toBe(USERNAME); | |
| 78 | }); | |
| 79 | ||
| 80 | test("an anonymous plan names no first admin: whoever claims the child becomes it", async () => { | |
| 81 | const res = await call("/api/v1/forges", { name: "claimed-later", dryRun: true }); | |
| 82 | expect(res.status).toBe(200); | |
| 83 | expect(((await res.json()) as { firstAdmin?: string | null }).firstAdmin).toBeNull(); | |
| 84 | }); |
new above
offline