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

Merged opened this

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

Merged

scout/forge:scout/7908-get-api-v1-user-next-steps-answers-a-spa → main 2 commits fork

  • Merged. scout/7908-get-api-v1-user-next-steps-answers-a-spa was merged into main as ea8545c.

Review

Sign in to review this change.
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";
132132 export { route as getApiV1User } from "./getApiV1User.ts";
133133 export { route as getApiV1UserAssigned } from "./getApiV1UserAssigned.ts";
134134 export { route as getApiV1UserKeys } from "./getApiV1UserKeys.ts";
135 export { route as getApiV1UserNextSteps } from "./getApiV1UserNextSteps.ts";
135136 export { route as getApiV1UserOrgs } from "./getApiV1UserOrgs.ts";
136137 export { route as getApiV1UserOriginApps } from "./getApiV1UserOriginApps.ts";
137138 export { route as getApiV1UserOrigins } from "./getApiV1UserOrigins.ts";
src/http/api/user.ts +11
@@ -13,6 +13,7 @@ import { addSshKey, changePassword, deleteSshKey, updateProfile } from "../../ac
1313 import { VERIFY_WORDS } from "../../actions/verify_words.ts";
1414 import { resendVerification } from "../../actions/verify.ts";
1515 import { listAssignedTo } from "../../core/issues.ts";
16 import { nextStepsFor } from "../../core/next-steps.ts";
1617 import { listKeys, publicKey } from "../../core/keys.ts";
1718 import { listOrgsForUser } from "../../core/orgs.ts";
1819 import { canRead, getReposByIds } from "../../core/repos.ts";
@@ -729,6 +730,16 @@ export function userRoutes(router: Router<Handler>): void {
729730 return timePhase(ctx.request, "render", () => json({ items, next_cursor: null }));
730731 });
731732
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
732743 router.add("DELETE", "/api/v1/user/pins/:owner/:name", (ctx) => {
733744 const denied = requireScope(ctx, "rw");
734745 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs