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

GET /api/v1/flags answers every declared flag with why, default, owner issue and live value, as MCP `flags` does #8019

Merged opened this

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

Merged

mason/forge:mason/8012-get-api-v1-flags-answers-every-declared → main 1 commit fork

  • Merged. mason/8012-get-api-v1-flags-answers-every-declared was merged into main as 89a25b1.

Review

Sign in to review this change.
5 files changed +110 −0
src/contract/openapi/routes/getApiV1Flags.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/flags",
6 summary:
7 "Every declared flag with why it exists, its default, the issue that owns removing it and its live value, as MCP `flags` answers (a person admin's token); answers { flags }",
8 auth: true,
9 refusals: { forbidden: "the token is not a person who administers the forge" },
10 };
src/contract/openapi/routes/index.ts +1
@@ -53,6 +53,7 @@ export { route as getApiV1Explore } from "./getApiV1Explore.ts";
5353 export { route as getApiV1Family } from "./getApiV1Family.ts";
5454 export { route as getApiV1Feed } from "./getApiV1Feed.ts";
5555 export { route as getApiV1FeedChips } from "./getApiV1FeedChips.ts";
56 export { route as getApiV1Flags } from "./getApiV1Flags.ts";
5657 export { route as getApiV1FleetGraph } from "./getApiV1FleetGraph.ts";
5758 export { route as getApiV1FleetOps } from "./getApiV1FleetOps.ts";
5859 export { route as getApiV1FleetOpsId } from "./getApiV1FleetOpsId.ts";
src/http/api.ts +2
@@ -319,6 +319,7 @@ import { labelsRoutes } from "./api/labels.ts";
319319 import { visionRoutes } from "./api/vision.ts";
320320 import { tokenRoutes } from "./api/tokens.ts";
321321 import { accountAdmin, adminRoutes } from "./api/admin.ts";
322 import { flagsRoutes } from "./api/flags.ts";
322323 import { issueRoutes, labelsHandler } from "./api/issues.ts";
323324 import { userRoutes } from "./api/user.ts";
324325 import { appRoutes } from "./api/app.ts";
@@ -1109,6 +1110,7 @@ export function apiRoutes(router: Router<Handler>): void {
11091110
11101111 userRoutes(router);
11111112 adminRoutes(router);
1113 flagsRoutes(router);
11121114
11131115 // Open read hooks (#1789): the owner, or an agent holding `telemetry:read`.
11141116 // Anyone else is refused; every agent read is logged.
src/http/api/flags.ts added +16
@@ -0,0 +1,16 @@
1 /** `GET /api/v1/flags`: what MCP `flags` answers, for the same reader (#8012). */
2
3 import { readFlags } from "../../actions/flags_view.ts";
4 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
5 import type { Handler } from "../context.ts";
6 import { apiError, json, unauthorized } from "../respond.ts";
7 import type { Router } from "../router.ts";
8
9 export function flagsRoutes(router: Router<Handler>): void {
10 router.add("GET", "/api/v1/flags", (ctx) => {
11 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
12 if (!ctx.auth.user) return unauthorized();
13 const out = readFlags(ctx.auth.user);
14 return out.ok ? json({ flags: out.flags }) : apiError("forbidden", 403, out.message);
15 });
16 }
test/api-flags.test.ts added +81
@@ -0,0 +1,81 @@
1 /** `GET /api/v1/flags` answers what MCP `flags` answers, for the same reader (#8012). */
2
3 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
4 import { mkdtemp, rm } from "node:fs/promises";
5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
7 import { createApp, type App } from "../src/app.ts";
8 import { makeFlags } from "../src/core/flags.ts";
9 import { createToken } from "../src/core/tokens.ts";
10 import { createUser, getUserByName } from "../src/core/users.ts";
11 import { serve, type Servers } from "../src/http/server.ts";
12 import { useFlagRegistry } from "../src/web/pages/flags.ts";
13 import { quietLogs } from "./helpers/globals.ts";
14
15 quietLogs();
16
17 const PASS = "a-long-flags-passphrase";
18 const reg = makeFlags({ alpha: { why: "shows the new board", default: false, issue: 5417 } });
19
20 describe("GET /api/v1/flags", () => {
21 let dataDir = "";
22 let app: App;
23 let servers: Servers;
24
25 const tokenOf = (name: string) => createToken(app.db, getUserByName(app.db, name)!.id, "t", "ro").token;
26 const get = (token: string | null) =>
27 fetch(`${servers.url}/api/v1/flags`, { headers: token ? { 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: "flags", 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-flags-"));
39 app = createApp({ dataDir, port: 0 });
40 servers = await serve(app);
41 for (const [username, isAdmin] of [["alice", true], ["bob", false], ["pixel", false]] as const) {
42 const made = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@x.test`, password: PASS, isAdmin });
43 if (!made.ok) throw new Error(username);
44 }
45 app.db.query("UPDATE user SET is_agent = 1, is_admin = 1 WHERE username = 'pixel'").run();
46 useFlagRegistry(reg);
47 }, 30_000);
48
49 afterAll(async () => {
50 useFlagRegistry(undefined);
51 servers.stop();
52 app.close();
53 await rm(dataDir, { recursive: true, force: true });
54 });
55
56 test("an admin person gets the rows MCP flags returns", async () => {
57 const res = await get(tokenOf("alice"));
58 expect(res.status).toBe(200);
59 const body = (await res.json()) as any;
60 expect(body.flags).toEqual([{ name: "alpha", why: "shows the new board", default: false, issue: 5417 }]);
61 expect(body).toEqual(await mcp(tokenOf("alice")));
62 });
63
64 test("an agent admin and a plain member get 403 in the page's words", async () => {
65 for (const name of ["pixel", "bob"]) {
66 const res = await get(tokenOf(name));
67 expect(res.status).toBe(403);
68 const body = (await res.json()) as any;
69 expect(JSON.stringify(body)).toContain("Flags are set by a forge admin.");
70 }
71 });
72
73 test("no token gets 401", async () => {
74 expect((await get(null)).status).toBe(401);
75 });
76
77 test("the route is in the OpenAPI document", async () => {
78 const doc = (await (await fetch(`${servers.url}/api/v1/openapi.json`)).json()) as any;
79 expect(doc.paths["/api/v1/flags"].get).toBeDefined();
80 });
81 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs