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

GET /api/v1/admin/subscriptions answers the crew's Claude accounts with scopes, budget and spend, never a token, as MCP `subscriptions` does #8065

Merged opened this

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

Merged

mason/forge:mason/8021-get-api-v1-admin-subscriptions-answers-t → main 5 commits fork 1 thread · 1 unresolved

  • Merged. mason/8021-get-api-v1-admin-subscriptions-answers-t was merged into main as 4419acc.

Review

Sign in to review this change.
7 files changed +132 −20
src/contract/openapi/routes/getApiV1AdminSubscriptions.ts added +10
@@ -0,0 +1,10 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: "/api/v1/admin/subscriptions",
6 summary:
7 "The crew's Claude accounts, as MCP `subscriptions` says it, never a token (a forge admin); answers { items: [{ id, name, kind, apiBase, scopes, budgetUsd, spentUsd, restingUntil, restingSaid, lastUsedAt }] }",
8 auth: true,
9 refusals: { not_admin: "the caller is not a forge admin (403)" },
10 };
src/contract/openapi/routes/index.ts +1
@@ -36,6 +36,7 @@ export { route as getApiV1AdminLimits } from "./getApiV1AdminLimits.ts";
3636 export { route as getApiV1AdminPeople } from "./getApiV1AdminPeople.ts";
3737 export { route as getApiV1AdminQuotas } from "./getApiV1AdminQuotas.ts";
3838 export { route as getApiV1AdminSettings } from "./getApiV1AdminSettings.ts";
39 export { route as getApiV1AdminSubscriptions } from "./getApiV1AdminSubscriptions.ts";
3940 export { route as getApiV1AdminTeams } from "./getApiV1AdminTeams.ts";
4041 export { route as getApiV1Agents } from "./getApiV1Agents.ts";
4142 export { route as getApiV1AgentsUsername } from "./getApiV1AgentsUsername.ts";
src/core/subscriptions.ts +21
@@ -750,3 +750,24 @@ export function crewHasCredential(
750750 (name) => (env[name] ?? "").trim() !== "",
751751 );
752752 }
753
754 /** What MCP `subscriptions` and `GET /api/v1/admin/subscriptions` list: each account, never its token. */
755 export function subscriptionRows(db: Database) {
756 const scopes = scopesOf(db);
757 const budgets = budgetsOf(db);
758 return listSubscriptions(db).map((s) => {
759 const b = budgets.get(s.id);
760 return {
761 id: s.id,
762 name: s.name,
763 kind: s.kind,
764 apiBase: s.apiBase,
765 scopes: scopes.get(s.id) ?? [],
766 budgetUsd: b?.budgetUsd ?? null,
767 spentUsd: b?.spentUsd ?? 0,
768 restingUntil: s.restingUntil,
769 restingSaid: s.restingSaid,
770 lastUsedAt: s.lastUsedAt,
771 };
772 });
773 }
src/http/api.ts +2
@@ -314,6 +314,7 @@ import { forgeRoutes } from "./api/forges.ts";
314314 import { releasesRoutes } from "./api/releases.ts";
315315 import { hookRoutes } from "./api/hooks.ts";
316316 import { agentRoutes } from "./api/agents.ts";
317 import { subscriptionRoutes } from "./api/subscriptions.ts";
317318 import { boardRoutes } from "./api/boards.ts";
318319 import { followingRoutes } from "./api/following.ts";
319320 import { labelsRoutes } from "./api/labels.ts";
@@ -3749,6 +3750,7 @@ export function apiRoutes(router: Router<Handler>): void {
37493750 }
37503751
37513752 agentRoutes(router);
3753 subscriptionRoutes(router);
37523754
37533755 router.add("GET", "/api/v1/repos/:owner/:name/crew", (ctx) => {
37543756 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
src/http/api/subscriptions.ts added +17
@@ -0,0 +1,17 @@
1 /** `GET /api/v1/admin/subscriptions`: what MCP `subscriptions` answers; `adminGate` refuses anyone but a forge admin first. */
2
3 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
4 import { subscriptionRows } from "../../core/subscriptions.ts";
5 import type { Handler } from "../context.ts";
6 import { json, requireScope, unauthorized } from "../respond.ts";
7 import type { Router } from "../router.ts";
8
9 export function subscriptionRoutes(router: Router<Handler>): void {
10 router.add("GET", "/api/v1/admin/subscriptions", (ctx) => {
11 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
12 if (!ctx.auth.user) return unauthorized();
13 const denied = requireScope(ctx, "ro");
outdated

Dead code. adminGate in src/http/admin_gate.ts already answers every /api/v1/admin/* path with 403 not_admin before this handler runs, as the author says in the pull. Remove this isAdmin line and its "forbidden" error, and keep the unauthorized() check. If the route should defend itself, make it return not_admin so it matches the gate.

14 if (denied) return denied;
15 return json({ items: subscriptionRows(ctx.app.db) });
16 });
17 }
src/http/mcp/tools/subscriptions.ts +2 −20
@@ -1,4 +1,4 @@
1 import { budgetsOf, listSubscriptions, scopesOf } from "../../../core/subscriptions.ts";
1 import { subscriptionRows } from "../../../core/subscriptions.ts";
22 import { good, type Tool } from "../shared.ts";
33
44 export const tool: Tool = {
@@ -9,24 +9,6 @@ export const tool: Tool = {
99 writes: false,
1010 adminOnly: true,
1111 run(ctx) {
12 const scopes = scopesOf(ctx.app.db);
13 const budgets = budgetsOf(ctx.app.db);
14 return good({
15 items: listSubscriptions(ctx.app.db).map((s) => {
16 const b = budgets.get(s.id);
17 return {
18 id: s.id,
19 name: s.name,
20 kind: s.kind,
21 apiBase: s.apiBase,
22 scopes: scopes.get(s.id) ?? [],
23 budgetUsd: b?.budgetUsd ?? null,
24 spentUsd: b?.spentUsd ?? 0,
25 restingUntil: s.restingUntil,
26 restingSaid: s.restingSaid,
27 lastUsedAt: s.lastUsedAt,
28 };
29 }),
30 });
12 return good({ items: subscriptionRows(ctx.app.db) });
3113 },
3214 };
test/api-admin-subscriptions.test.ts added +79
@@ -0,0 +1,79 @@
1 /** `GET /api/v1/admin/subscriptions` answers what MCP `subscriptions` answers, never a token. */
2 import { afterAll, beforeAll, 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 { addSubscription } from "../src/core/subscriptions.ts";
8 import { createToken } from "../src/core/tokens.ts";
9 import { createUser } from "../src/core/users.ts";
10 import { serve, type Servers } from "../src/http/server.ts";
11
12 const SECRET = "sk-ant-api03-seeded-secret-text-0123456789abcdef";
13 let dataDir = "";
14 let app: App;
15 let servers: Servers;
16 let admin = "";
17 let member = "";
18
19 const get = (token?: string) =>
20 fetch(`${servers.url}/api/v1/admin/subscriptions`, token ? { headers: { authorization: `Bearer ${token}` } } : {});
21
22 beforeAll(async () => {
23 dataDir = await mkdtemp(join(tmpdir(), "forge-api-subs-"));
24 app = createApp({ dataDir, port: 0, url: "https://forge.example" });
25 servers = await serve(app);
26 const mk = async (username: string, extra: Record<string, unknown> = {}) => {
27 const u = await createUser(app.db, {
28 origin: "seed", trusted: true, username,
29 email: `${username}@forge.local`, password: "a-good-passphrase", ...extra,
30 });
31 if (!u.ok) throw new Error(u.error);
32 return u.value;
33 };
34 const alice = await mk("alice", { adminIfFirst: true });
35 const bob = await mk("bob");
36 admin = createToken(app.db, alice.id, "a", "admin").token;
37 member = createToken(app.db, bob.id, "b", "admin").token;
38 const added = addSubscription(app.db, dataDir, { name: "work", token: SECRET });
39 if (!added.ok) throw new Error(added.error);
40 }, 30_000);
41 afterAll(async () => {
42 await servers.stop();
43 app.close();
44 await rm(dataDir, { recursive: true, force: true });
45 });
46
47 async function mcp(token: string): Promise<unknown> {
48 const res = await fetch(`${servers.url}/mcp`, {
49 method: "POST",
50 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` },
51 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "subscriptions", arguments: {} } }),
52 });
53 return JSON.parse(((await res.json()) as any).result.content[0].text);
54 }
55
56 test("an admin gets the rows MCP subscriptions returns, without the token", async () => {
57 const res = await get(admin);
58 expect(res.status).toBe(200);
59 const text = await res.text();
60 expect(text).not.toContain(SECRET);
61 const body = JSON.parse(text) as { items: any[] };
62 expect(body).toEqual((await mcp(admin)) as any);
63 expect(body.items.some((r) => r.name === "work")).toBe(true);
64 expect(Object.keys(body.items[0]).sort()).toEqual(
65 ["apiBase", "budgetUsd", "id", "kind", "lastUsedAt", "name", "restingSaid", "restingUntil", "scopes", "spentUsd"],
66 );
67 });
68
69 test("a member is 403 not_admin, no token is 401", async () => {
70 const res = await get(member);
71 expect(res.status).toBe(403);
72 expect(((await res.json()) as any).error).toBe("not_admin");
73 expect((await get()).status).toBe(401);
74 });
75
76 test("openapi lists the route", async () => {
77 const doc = (await (await fetch(`${servers.url}/api/v1/openapi.json`)).json()) as any;
78 expect(doc.paths["/api/v1/admin/subscriptions"].get).toBeDefined();
79 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs