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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8025-get-api-v1-cloud-accounts-answers-the-fo → main 3 commits fork 1 thread · 1 unresolved
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"; | ||
| 50 | 50 | export { route as getApiV1BoardsSlug } from "./getApiV1BoardsSlug.ts"; |
| 51 | 51 | export { route as getApiV1BoardsSlugPanelsId } from "./getApiV1BoardsSlugPanelsId.ts"; |
| 52 | 52 | export { route as getApiV1BoardsSources } from "./getApiV1BoardsSources.ts"; |
| 53 | export { route as getApiV1CloudAccounts } from "./getApiV1CloudAccounts.ts"; | |
| 53 | 54 | export { route as getApiV1Contribute } from "./getApiV1Contribute.ts"; |
| 54 | 55 | export { route as getApiV1ConversationTurns } from "./getApiV1ConversationTurns.ts"; |
| 55 | 56 | export { route as getApiV1CrewNow } from "./getApiV1CrewNow.ts"; |
src/http/api.ts +3
| @@ -334,6 +334,7 @@ import { conversationConfirmRoutes } from "./api/conversation_confirms.ts"; | ||
| 334 | 334 | import { conversationTalkRoutes } from "./api/conversation_talk.ts"; |
| 335 | 335 | import { orgRoutes } from "./api/orgs.ts"; |
| 336 | 336 | import { promptRoutes } from "./api/prompts.ts"; |
| 337 | import { cloudRoutes } from "./api/cloud.ts"; | |
| 337 | 338 | import { secretRoutes } from "./api/secrets.ts"; |
| 338 | 339 | import { drillRoutes } from "./api/drills.ts"; |
| 339 | 340 | import { upstreamRoutes } from "./api/upstream.ts"; |
| @@ -3355,6 +3356,8 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3355 | 3356 | |
| 3356 | 3357 | promptRoutes(router); |
| 3357 | 3358 | |
| 3359 | cloudRoutes(router); | |
| 3360 | ||
| 3358 | 3361 | secretRoutes(router); |
| 3359 | 3362 | drillRoutes(router); |
| 3360 | 3363 |
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 { | |
| 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 | }); |
new above
offline
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.