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

GET /api/v1/cloud/accounts answers the forge's cloud accounts with each token's use and today's calls, never a secret, as MCP `cloud_accounts` does #8319

Merged opened this

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

Merged

scout/forge:scout/8025-get-api-v1-cloud-accounts-answers-the-fo → main 3 commits fork 1 thread · 1 unresolved

  • Merged. scout/8025-get-api-v1-cloud-accounts-answers-the-fo was merged into main as ad90590.

Review

Sign in to review this change.
6 files changed +151 −0
docs/features/cloud.md added +16
@@ -0,0 +1,16 @@
1 # Cloud accounts
2
3 The forge keeps the provider tokens (a cloud vendor's API token) its repositories use, sealed. Four doors show them, all a forge admin's alone; anyone else finds no such page, screen, tool or route. No door ever returns a secret: a token is typed once, checked, sealed, and never drawn, kept in a note or sent back.
4
5 - **Page**: Settings → Cloud accounts, `/settings/cloud`. Each account with what it is used for, when it was verified and last used, and today's vendor calls. Attach, verify, allow writes, rename, rotate, audit and remove are forms on the page.
6 - **Terminal**: the "Cloud accounts" screen (`src/tui/screens/cloud.ts`), opened from the palette. `a` attaches (token typed masked), `v` re-verifies, `r` rotates, `n` renames, `x` removes. The checks are the page's (`src/actions/cloud.ts`).
7 - **MCP**: `cloud_accounts` takes no arguments and returns the accounts with each token's use, verification, write permission and today's calls. It counts as a write, so a `ro` token never lists them.
8 - **HTTP**: `GET /api/v1/cloud/accounts` returns the same body as `cloud_accounts` (both build it with `cloudOverview`).
9
10 ```
11 curl -H "authorization: Bearer $FORGE_TOKEN" "$FORGE_URL/api/v1/cloud/accounts"
12 ```
13
14 Answers: `401` with no signed-in user, `403` for a read-only token (every vendor call is audited, so listing is a write-scoped act), `404` for anyone who is not a forge admin.
15
16 Not done: the route only lists. Attaching, rotating and removing stay on the page and in the terminal, where the token can be typed masked.
src/contract/openapi/routes/getApiV1CloudAccounts.ts added +12
@@ -0,0 +1,12 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: "/api/v1/cloud/accounts",
6 summary: "The forge's cloud accounts, each attached provider token with its use, verification, write permission and today's calls, never a secret; a forge administrator's, with a token that may write",
7 auth: true,
8 refusals: {
9 forbidden: "a read-only token cannot list cloud accounts",
10 not_found: "only a forge administrator sees this route",
11 },
12 };
src/contract/openapi/routes/index.ts +1
@@ -50,6 +50,7 @@ export { route as getApiV1BoardsPins } from "./getApiV1BoardsPins.ts";
5050 export { route as getApiV1BoardsSlug } from "./getApiV1BoardsSlug.ts";
5151 export { route as getApiV1BoardsSlugPanelsId } from "./getApiV1BoardsSlugPanelsId.ts";
5252 export { route as getApiV1BoardsSources } from "./getApiV1BoardsSources.ts";
53 export { route as getApiV1CloudAccounts } from "./getApiV1CloudAccounts.ts";
5354 export { route as getApiV1Contribute } from "./getApiV1Contribute.ts";
5455 export { route as getApiV1ConversationTurns } from "./getApiV1ConversationTurns.ts";
5556 export { route as getApiV1CrewNow } from "./getApiV1CrewNow.ts";
src/http/api.ts +3
@@ -334,6 +334,7 @@ import { conversationConfirmRoutes } from "./api/conversation_confirms.ts";
334334 import { conversationTalkRoutes } from "./api/conversation_talk.ts";
335335 import { orgRoutes } from "./api/orgs.ts";
336336 import { promptRoutes } from "./api/prompts.ts";
337 import { cloudRoutes } from "./api/cloud.ts";
337338 import { secretRoutes } from "./api/secrets.ts";
338339 import { drillRoutes } from "./api/drills.ts";
339340 import { upstreamRoutes } from "./api/upstream.ts";
@@ -3355,6 +3356,8 @@ export function apiRoutes(router: Router<Handler>): void {
33553356
33563357 promptRoutes(router);
33573358
3359 cloudRoutes(router);
3360
33583361 secretRoutes(router);
33593362 drillRoutes(router);
33603363
src/http/api/cloud.ts added +30
@@ -0,0 +1,30 @@
1 /**
2 * `GET /api/v1/cloud/accounts`: the forge's cloud accounts with each token's
3 * use and today's calls, never a secret — MCP `cloud_accounts` over HTTP. A
4 * forge admin's alone (anyone else sees no such route), and a token that may
5 * write, because every vendor call is audited.
6 */
7
8 import { usesByVendor, type VendorUse } from "../../cloud/declared.ts";
9 import { cloudOverview } from "../../cloud/overview.ts";
10 import { scopeAllows } from "../../core/tokens.ts";
11 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
12 import type { Handler } from "../context.ts";
13 import { apiError, json, notFound, unauthorized } from "../respond.ts";
14 import type { Router } from "../router.ts";
15
16 export function cloudRoutes(router: Router<Handler>): void {

This adds a new door (the HTTP route) with no docs/features/<name>.md how-to. Add docs/features/cloud.md covering it, the existing terminal screen, and the MCP tool.

17 router.add("GET", "/api/v1/cloud/accounts", async (ctx) => {
18 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
19 const user = ctx.auth.user;
20 if (!user || ctx.auth.scope === undefined) return unauthorized();
21 if (!scopeAllows(ctx.auth.scope, "rw"))
22 return apiError("forbidden", 403, "a read-only token cannot list cloud accounts");
23 if (!user.isAdmin) return notFound();
24 const uses = await usesByVendor(ctx.app.db, ctx.app.reposRoot, user).catch(
25 () => new Map<string, VendorUse[]>(),
26 );
27 const seen = cloudOverview(ctx.app, user, uses);
28 return seen.ok ? json(seen.value) : notFound();
29 });
30 }
test/api-cloud-accounts.test.ts added +89
@@ -0,0 +1,89 @@
1 /** `GET /api/v1/cloud/accounts` is MCP `cloud_accounts` over HTTP: the same object, the same gate, never a secret. */
2 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { addAccount, net } from "../src/actions/cloud.ts";
8 import { createToken } from "../src/core/tokens.ts";
9 import { createUser, getUserByName } from "../src/core/users.ts";
10 import { serve, type Servers } from "../src/http/server.ts";
11 import { quietLogs } from "./helpers/globals.ts";
12
13 quietLogs();
14
15 const SECRET = "ghp_first9Zq4TxLm7Kp2Vb8nWcR5";
16 net.fetch = async () => new Response(JSON.stringify({ login: "octo" }), { status: 200 });
17
18 describe("GET /api/v1/cloud/accounts", () => {
19 let dataDir = "";
20 let app: App;
21 let servers: Servers;
22 let admin = "";
23 let roAdmin = "";
24 let bob = "";
25
26 const get = (token?: string) =>
27 fetch(`${servers.url}/api/v1/cloud/accounts`, token ? { headers: { authorization: `Bearer ${token}` } } : {});
28 const mcp = async (token: string) => {
29 const res = await fetch(`${servers.url}/mcp`, {
30 method: "POST",
31 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` },
32 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "cloud_accounts", arguments: {} } }),
33 });
34 return JSON.parse(((await res.json()) as any).result.content[0].text);
35 };
36
37 beforeAll(async () => {
38 dataDir = await mkdtemp(join(tmpdir(), "forge-api-cloud-"));
39 app = createApp({ dataDir, port: 0 });
40 servers = await serve(app);
41 for (const [username, adminIfFirst] of [["alice", true], ["bob", false]] as const) {
42 const made = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@f.test`, password: "a-good-passphrase", adminIfFirst });
43 if (!made.ok) throw new Error(made.error);
44 }
45 const alice = getUserByName(app.db, "alice")!;
46 admin = createToken(app.db, alice.id, "admin", "admin").token;
47 roAdmin = createToken(app.db, alice.id, "ro", "ro").token;
48 bob = createToken(app.db, getUserByName(app.db, "bob")!.id, "admin", "admin").token;
49 const added = await addAccount(app, alice, "github", "main", SECRET);
50 if (!added.ok) throw new Error(String(added.error));
51 }, 30_000);
52
53 afterAll(async () => {
54 servers.stop();
55 app.close();
56 await rm(dataDir, { recursive: true, force: true });
57 });
58
59 test("an admin gets the object MCP cloud_accounts returns, with no secret in it", async () => {
60 const res = await get(admin);
61 expect(res.status).toBe(200);
62 const text = await res.text();
63 expect(text).not.toContain(SECRET);
64 const body = JSON.parse(text);
65 expect(body.accounts).toHaveLength(1);
66 expect(body.accounts[0]).toMatchObject({ provider: "github", name: "main" });
67 expect(body).toEqual(await mcp(admin));
68 });
69
70 test("a member gets 404, and no token 401", async () => {
71 const res = await get(bob);
72 expect(res.status).toBe(404);
73 expect(((await res.json()) as any).error).toBe("not_found");
74 expect((await get()).status).toBe(401);
75 });
76
77 test("a ro admin token gets 403", async () => {
78 const res = await get(roAdmin);
79 expect(res.status).toBe(403);
80 const body: any = await res.json();
81 expect(body.error).toBe("forbidden");
82 expect(body.message).toContain("read-only token cannot list cloud accounts");
83 });
84
85 test("the route is in the OpenAPI document", async () => {
86 const doc: any = await (await fetch(`${servers.url}/api/v1/openapi.json`)).json();
87 expect(doc.paths["/api/v1/cloud/accounts"].get).toBeDefined();
88 });
89 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs