Public: anyone can read this; the crew can change it.
GET /api/v1/user/next-steps answers a spawned forge's first three steps for its owner, as MCP `next_steps` does #7913
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7908-get-api-v1-user-next-steps-answers-a-spa → main 2 commits fork
4 files
changed +223
−0
src/contract/openapi/routes/getApiV1UserNextSteps.ts added +8
| @@ -0,0 +1,8 @@ | ||
| 1 | import type { Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { | |
| 4 | method: "get", | |
| 5 | path: "/api/v1/user/next-steps", | |
| 6 | summary: "On a spawned forge, its owner's first three steps (everyday, apps, tell), each with whether it is done; needs a token with scope ro or better; answers { steps } with [] elsewhere, for an agent, or for anyone but the owner; the same JSON as MCP next_steps", | |
| 7 | auth: true, | |
| 8 | }; |
src/contract/openapi/routes/index.ts +1
| @@ -132,6 +132,7 @@ export { route as getApiV1Tokens } from "./getApiV1Tokens.ts"; | ||
| 132 | 132 | export { route as getApiV1User } from "./getApiV1User.ts"; |
| 133 | 133 | export { route as getApiV1UserAssigned } from "./getApiV1UserAssigned.ts"; |
| 134 | 134 | export { route as getApiV1UserKeys } from "./getApiV1UserKeys.ts"; |
| 135 | export { route as getApiV1UserNextSteps } from "./getApiV1UserNextSteps.ts"; | |
| 135 | 136 | export { route as getApiV1UserOrgs } from "./getApiV1UserOrgs.ts"; |
| 136 | 137 | export { route as getApiV1UserOriginApps } from "./getApiV1UserOriginApps.ts"; |
| 137 | 138 | export { route as getApiV1UserOrigins } from "./getApiV1UserOrigins.ts"; |
src/http/api/user.ts +11
| @@ -13,6 +13,7 @@ import { addSshKey, changePassword, deleteSshKey, updateProfile } from "../../ac | ||
| 13 | 13 | import { VERIFY_WORDS } from "../../actions/verify_words.ts"; |
| 14 | 14 | import { resendVerification } from "../../actions/verify.ts"; |
| 15 | 15 | import { listAssignedTo } from "../../core/issues.ts"; |
| 16 | import { nextStepsFor } from "../../core/next-steps.ts"; | |
| 16 | 17 | import { listKeys, publicKey } from "../../core/keys.ts"; |
| 17 | 18 | import { listOrgsForUser } from "../../core/orgs.ts"; |
| 18 | 19 | import { canRead, getReposByIds } from "../../core/repos.ts"; |
| @@ -729,6 +730,16 @@ export function userRoutes(router: Router<Handler>): void { | ||
| 729 | 730 | return timePhase(ctx.request, "render", () => json({ items, next_cursor: null })); |
| 730 | 731 | }); |
| 731 | 732 | |
| 733 | // The first three steps on a spawned forge, as MCP `next_steps`: empty | |
| 734 | // elsewhere, for an agent, and for anyone but the forge's owner. | |
| 735 | router.add("GET", "/api/v1/user/next-steps", async (ctx) => { | |
| 736 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 737 | const denied = requireScope(ctx, "ro"); | |
| 738 | if (denied) return denied; | |
| 739 | const steps = await nextStepsFor(ctx.app, ctx.auth.user); | |
| 740 | return json({ steps: steps ?? [] }); | |
| 741 | }); | |
| 742 | ||
| 732 | 743 | router.add("DELETE", "/api/v1/user/pins/:owner/:name", (ctx) => { |
| 733 | 744 | const denied = requireScope(ctx, "rw"); |
| 734 | 745 | if (denied) return denied; |
test/api-user-next-steps.test.ts added +203
| @@ -0,0 +1,203 @@ | ||
| 1 | /** | |
| 2 | * GET /api/v1/user/next-steps (#7908): the owner of a spawned forge gets the | |
| 3 | * same three steps as MCP `next_steps`; anyone else, or an agent, gets []. | |
| 4 | */ | |
| 5 | ||
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createRepo } from "../src/actions/repos.ts"; | |
| 11 | import { childDirOf, spawnForge } from "../src/actions/spawn.ts"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { addCrew, createAgentRow, setCrewRole } from "../src/core/agents.ts"; | |
| 14 | import { createToken } from "../src/core/tokens.ts"; | |
| 15 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 16 | import { commitFiles } from "../src/git/write.ts"; | |
| 17 | import { repoDir } from "../src/git/repos.ts"; | |
| 18 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 19 | import { firstBoot } from "../src/spawn/first-boot.ts"; | |
| 20 | import { hostedPlacement } from "../src/spawn/placement.ts"; | |
| 21 | import { quietLogs } from "./helpers/globals.ts"; | |
| 22 | import { passkeyFields } from "./helpers/passkey.ts"; | |
| 23 | ||
| 24 | quietLogs(); | |
| 25 | ||
| 26 | const PASSWORD = "a-long-first-login-passphrase"; | |
| 27 | const FORGE_JSON = JSON.stringify({ | |
| 28 | app: { run: "bun run index.ts", health: "/" }, | |
| 29 | }); | |
| 30 | ||
| 31 | describe("GET /api/v1/user/next-steps", () => { | |
| 32 | let dir = ""; | |
| 33 | let parent: App; | |
| 34 | let parentServers: Servers; | |
| 35 | let child: App; | |
| 36 | let servers: Servers; | |
| 37 | let owner = ""; | |
| 38 | ||
| 39 | const url = (path: string) => `${servers.url}${path}`; | |
| 40 | ||
| 41 | beforeAll(async () => { | |
| 42 | dir = await mkdtemp(join(tmpdir(), "forge-first-login-")); | |
| 43 | parent = createApp({ dataDir: dir, url: "https://forge.test", port: 0 }); | |
| 44 | const made = await createUser(parent.db, { | |
| 45 | origin: "seed", | |
| 46 | trusted: true, | |
| 47 | username: "alice", | |
| 48 | email: "[email protected]", | |
| 49 | password: PASSWORD, | |
| 50 | isAdmin: true, | |
| 51 | }); | |
| 52 | if (!made.ok) throw new Error("user"); | |
| 53 | const alice = made.value; | |
| 54 | const repo = await createRepo(parent, alice, { name: "bakery" }); | |
| 55 | if (!repo.ok) throw new Error("repo"); | |
| 56 | const c = await commitFiles(repoDir(parent.reposRoot, "alice", "bakery"), { | |
| 57 | branch: "main", | |
| 58 | message: "bakery", | |
| 59 | files: [ | |
| 60 | { path: "forge.json", content: FORGE_JSON }, | |
| 61 | { | |
| 62 | path: "index.ts", | |
| 63 | content: "Bun.serve({fetch(){return new Response('hi')}})\n", | |
| 64 | }, | |
| 65 | ], | |
| 66 | author: { name: "a", email: "[email protected]" }, | |
| 67 | }); | |
| 68 | if (!c.ok) throw new Error("commit"); | |
| 69 | const atlas = await createUser(parent.db, { | |
| 70 | origin: "seed", | |
| 71 | username: "atlas", | |
| 72 | email: "[email protected]", | |
| 73 | agent: true, | |
| 74 | }); | |
| 75 | if (!atlas.ok) throw new Error("agent"); | |
| 76 | createAgentRow(parent.db, { | |
| 77 | userId: atlas.value.id, | |
| 78 | brief: "plan", | |
| 79 | createdBy: alice.id, | |
| 80 | }); | |
| 81 | addCrew(parent.db, repo.value.id, atlas.value.id, alice.id); | |
| 82 | setCrewRole(parent.db, repo.value.id, atlas.value.id, "planner"); | |
| 83 | parentServers = await serve(parent); | |
| 84 | const spawned = await spawnForge( | |
| 85 | parent, | |
| 86 | alice, | |
| 87 | { | |
| 88 | name: "maria", | |
| 89 | org: "marias", | |
| 90 | apps: ["alice/bakery"], | |
| 91 | carryBriefs: true, | |
| 92 | carrySubscriptions: false, | |
| 93 | direction: "Run Maria's bakery.", | |
| 94 | }, | |
| 95 | { | |
| 96 | catalog: [ | |
| 97 | { ref: "alice/bakery", readable: true, bytes: 10, forgeJsonBytes: 0 }, | |
| 98 | ], | |
| 99 | briefs: [{ agent: "atlas", text: "plan" }], | |
| 100 | subscriptions: [], | |
| 101 | }, | |
| 102 | { | |
| 103 | placement: hostedPlacement({ host: "forge.test" }), | |
| 104 | request: new Request("https://forge.test/x", { | |
| 105 | method: "POST", | |
| 106 | headers: { origin: "https://forge.test" }, | |
| 107 | }), | |
| 108 | url: new URL("https://forge.test/x"), | |
| 109 | }, | |
| 110 | ); | |
| 111 | if (!spawned.ok) throw new Error(JSON.stringify(spawned.error)); | |
| 112 | child = createApp({ | |
| 113 | dataDir: childDirOf(parent, spawned.value.bodyId), | |
| 114 | port: 0, | |
| 115 | }); | |
| 116 | await firstBoot(child); | |
| 117 | servers = await serve(child); | |
| 118 | const link = spawned.value.claimUrl!; | |
| 119 | const token = link.split("/claim#")[1]!; | |
| 120 | const who = { token, username: "maria", email: "[email protected]" }; | |
| 121 | const claimed = await fetch(url("/claim"), { | |
| 122 | method: "POST", | |
| 123 | headers: { origin: servers.url }, | |
| 124 | body: new URLSearchParams({ | |
| 125 | ...who, | |
| 126 | ...(await passkeyFields(servers.url, "/claim/options", who)), | |
| 127 | }), | |
| 128 | redirect: "manual", | |
| 129 | }); | |
| 130 | if (claimed.status !== 303) throw new Error(`claim ${claimed.status}`); | |
| 131 | owner = (claimed.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 132 | }); | |
| 133 | afterAll(async () => { | |
| 134 | await servers.stop(); | |
| 135 | await parentServers.stop(); | |
| 136 | child.close(); | |
| 137 | parent.close(); | |
| 138 | await rm(dir, { recursive: true, force: true }); | |
| 139 | }); | |
| 140 | ||
| 141 | const get = (headers: Record<string, string>) => | |
| 142 | fetch(url("/api/v1/user/next-steps"), { headers }); | |
| 143 | const bearer = (id: string) => ({ | |
| 144 | authorization: `Bearer ${createToken(child.db, id, "ro", "ro").token}`, | |
| 145 | }); | |
| 146 | ||
| 147 | test("the owner gets three steps, the same as MCP next_steps", async () => { | |
| 148 | const maria = getUserByName(child.db, "maria")!; | |
| 149 | const res = await get(bearer(maria.id)); | |
| 150 | expect(res.status).toBe(200); | |
| 151 | const rest = (await res.json()) as { steps: { key: string; done: boolean }[] }; | |
| 152 | expect(rest.steps.map((s) => s.key)).toEqual(["everyday", "apps", "tell"]); | |
| 153 | const mcp = await fetch(url("/mcp"), { | |
| 154 | method: "POST", | |
| 155 | headers: { | |
| 156 | "content-type": "application/json", | |
| 157 | accept: "application/json, text/event-stream", | |
| 158 | ...bearer(maria.id), | |
| 159 | }, | |
| 160 | body: JSON.stringify({ | |
| 161 | jsonrpc: "2.0", | |
| 162 | id: 1, | |
| 163 | method: "tools/call", | |
| 164 | params: { name: "next_steps", arguments: {} }, | |
| 165 | }), | |
| 166 | }); | |
| 167 | const body = (await mcp.json()) as any; | |
| 168 | expect(rest).toEqual(JSON.parse(body.result.content[0].text)); | |
| 169 | }); | |
| 170 | ||
| 171 | test("the owner's session works too", async () => { | |
| 172 | const res = await get({ cookie: owner }); | |
| 173 | expect(((await res.json()) as any).steps).toHaveLength(3); | |
| 174 | }); | |
| 175 | ||
| 176 | test("another account gets []", async () => { | |
| 177 | const made = await createUser(child.db, { | |
| 178 | origin: "seed", | |
| 179 | username: "stranger", | |
| 180 | email: "[email protected]", | |
| 181 | password: PASSWORD, | |
| 182 | }); | |
| 183 | if (!made.ok) throw new Error("user"); | |
| 184 | const res = await get(bearer(made.value.id)); | |
| 185 | expect(await res.json()).toEqual({ steps: [] }); | |
| 186 | }); | |
| 187 | ||
| 188 | test("an agent's token gets [], never the owner's steps", async () => { | |
| 189 | const made = await createUser(child.db, { | |
| 190 | origin: "seed", | |
| 191 | username: "robot", | |
| 192 | email: "[email protected]", | |
| 193 | agent: true, | |
| 194 | }); | |
| 195 | if (!made.ok) throw new Error("agent"); | |
| 196 | const res = await get(bearer(made.value.id)); | |
| 197 | expect(await res.json()).toEqual({ steps: [] }); | |
| 198 | }); | |
| 199 | ||
| 200 | test("anonymous is a 401", async () => { | |
| 201 | expect((await get({})).status).toBe(401); | |
| 202 | }); | |
| 203 | }); |
new above
offline