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

GET /api/v1/admin/quotas and PUT /api/v1/admin/quotas/{key} read and set the per-account quotas, as MCP `quotas` does #7912

Merged opened this

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

Merged

scout/forge:scout/7907-get-api-v1-admin-quotas-and-put-api-v1-a → main 2 commits fork

  • Merged. scout/7907-get-api-v1-admin-quotas-and-put-api-v1-a was merged into main as 5788a4c.

Review

Sign in to review this change.
6 files changed +164 −1
docs/OPERATING.md +1 −1

Diff not shown — a line 5,045 characters wide (limit 2,000). +1 −1

View file
src/contract/openapi/routes/getApiV1AdminQuotas.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/quotas",
6 summary:
7 "Read the per-account quotas (a person admin's token, any scope); answers { quotas: { repositories, apps, storage, previews, runs, checks }, changes }, each a number or null for none",
8 auth: true,
9 refusals: { not_found: "the token is not a person who administers the forge" },
10 };
src/contract/openapi/routes/index.ts +2
@@ -33,6 +33,7 @@ export { route as getApiV1AdminCapacity } from "./getApiV1AdminCapacity.ts";
3333 export { route as getApiV1AdminInvites } from "./getApiV1AdminInvites.ts";
3434 export { route as getApiV1AdminLimits } from "./getApiV1AdminLimits.ts";
3535 export { route as getApiV1AdminPeople } from "./getApiV1AdminPeople.ts";
36 export { route as getApiV1AdminQuotas } from "./getApiV1AdminQuotas.ts";
3637 export { route as getApiV1AdminSettings } from "./getApiV1AdminSettings.ts";
3738 export { route as getApiV1AdminTeams } from "./getApiV1AdminTeams.ts";
3839 export { route as getApiV1Agents } from "./getApiV1Agents.ts";
@@ -273,6 +274,7 @@ export { route as postApiV1Users } from "./postApiV1Users.ts";
273274 export { route as postMcp } from "./postMcp.ts";
274275 export { route as putApiV1AdminCapacityKey } from "./putApiV1AdminCapacityKey.ts";
275276 export { route as putApiV1AdminLimitsKey } from "./putApiV1AdminLimitsKey.ts";
277 export { route as putApiV1AdminQuotasKey } from "./putApiV1AdminQuotasKey.ts";
276278 export { route as putApiV1AdminTeamsSlug } from "./putApiV1AdminTeamsSlug.ts";
277279 export { route as putApiV1BoardsSlug } from "./putApiV1BoardsSlug.ts";
278280 export { route as putApiV1Front } from "./putApiV1Front.ts";
src/contract/openapi/routes/putApiV1AdminQuotasKey.ts added +15
@@ -0,0 +1,15 @@
1 import { QUOTA_KEYS } from "../../../settings/quotas.ts";
2 import type { Route } from "../shared.ts";
3
4 export const route: Route = {
5 method: "put",
6 path: "/api/v1/admin/quotas/{key}",
7 summary: `Set one per-account quota, or send value null or "" to clear it (a person admin's token, admin scope); {key} is one of ${QUOTA_KEYS.join(", ")}; answers { key, value, quotas }`,
8 auth: true,
9 body: { value: "positive integer, null or empty string" },
10 refusals: {
11 not_found: "the token is not a person who administers the forge (404)",
12 no_such_quota: "{key} is not a quota (422)",
13 invalid_quota: "the value is not a positive whole number (422)",
14 },
15 };
src/http/api/admin.ts +22
@@ -105,6 +105,28 @@ export function adminRoutes(router: Router<Handler>): void {
105105 );
106106 });
107107
108 // Per-account quotas (`settings/quotas.ts`), key by key: the REST twin of MCP `quotas`.
109 router.add("GET", "/api/v1/admin/quotas", (ctx) => {
110 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
111 const denied = accountAdmin(ctx, "ro");
112 if (denied) return denied;
113 const read = readQuotas(ctx.app, ctx.auth.user);
114 return read.ok ? json({ quotas: read.value.quotas, changes: read.value.changes }) : notFound();
115 });
116
117 router.add("PUT", "/api/v1/admin/quotas/:key", async (ctx) => {
118 const denied = accountAdmin(ctx, "admin");
119 if (denied) return denied;
120 const input = await body(ctx.request);
121 const key = ctx.params.key!;
122 // A body without `value` is a malformed request, not a request to clear: null clears.
123 if (!("value" in input)) return apiError("invalid_quota", 422, 'send {"value": N}, or {"value": null} for no limit');
124 const changed = changeQuotas(ctx.app, ctx.auth.user, { [key]: input.value });
125 if (changed.ok) return json({ key, value: changed.value[key as keyof typeof changed.value], quotas: changed.value });
126 if (changed.error === "not_found") return notFound();
127 return apiError(changed.error, 422, changed.error === "no_such_quota" ? "no such quota" : "a quota is a positive whole number, or null for no limit");
128 });
129
108130 // Crew teams (`core/teams.ts`): a forge admin who is a person; the page's door.
109131 router.add("GET", "/api/v1/admin/teams", (ctx) => {
110132 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
test/api-admin-quotas.test.ts added +114
@@ -0,0 +1,114 @@
1 /** `GET`/`PUT /api/v1/admin/quotas` read and set the per-account quotas, as MCP `quotas` does. */
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 { signUp } from "../src/actions/users.ts";
8 import { serve, type Servers } from "../src/http/server.ts";
9 import { quietLogs } from "./helpers/globals.ts";
10 import { seedAdmin } from "./helpers/admin.ts";
11 import { fixtureSlot } from "./helpers/slot.ts";
12
13 quietLogs();
14
15 const PASS = "a-long-member-passphrase";
16 let dataDir = "";
17 let app: App;
18 let servers: Servers;
19 let admin = "";
20 let ro = "";
21 let member = "";
22
23 async function mint(username: string, scope: string): Promise<string> {
24 const res = await fetch(`${servers.url}/api/v1/tokens`, {
25 method: "POST",
26 headers: { "content-type": "application/json" },
27 body: JSON.stringify({ username, password: PASS, name: scope, scope, adminConfirm: true }),
28 });
29 expect(res.status).toBe(201);
30 return ((await res.json()) as { token: string }).token;
31 }
32 const get = (token: string) =>
33 fetch(`${servers.url}/api/v1/admin/quotas`, { headers: { authorization: `Bearer ${token}` } });
34 const put = (token: string, key: string, value: unknown) =>
35 fetch(`${servers.url}/api/v1/admin/quotas/${key}`, {
36 method: "PUT",
37 headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
38 body: JSON.stringify({ value }),
39 });
40 const quotas = async (token: string) =>
41 ((await (await get(token)).json()) as { quotas: Record<string, number | null> }).quotas;
42
43 beforeAll(async () => {
44 dataDir = await mkdtemp(join(tmpdir(), "forge-api-quotas-"));
45 app = createApp({ dataDir, port: 0, url: "https://forge.example" });
46 servers = await serve(app);
47 await seedAdmin(app, { username: "olive", email: "[email protected]", password: PASS });
48 const made = await signUp(app, { username: "bob", email: "[email protected]", password: PASS }, { slot: fixtureSlot(app) });
49 if (!made.ok) throw new Error(made.error);
50 admin = await mint("olive", "admin");
51 ro = await mint("olive", "ro");
52 member = await mint("bob", "rw");
53 });
54 afterAll(async () => {
55 await servers.stop();
56 await rm(dataDir, { recursive: true, force: true });
57 });
58
59 test("reads every quota, null when unset, with any scope", async () => {
60 for (const token of [admin, ro]) {
61 const res = await get(token);
62 expect(res.status).toBe(200);
63 const q = ((await res.json()) as { quotas: Record<string, number | null> }).quotas;
64 expect(Object.keys(q).sort()).toEqual(["apps", "checks", "previews", "repositories", "runs", "storage"]);
65 expect(q["apps"]).toBeNull();
66 }
67 });
68
69 test("sets, then clears with null or an empty string", async () => {
70 const set = await put(admin, "apps", 3);
71 expect(set.status).toBe(200);
72 expect(((await set.json()) as { value: number }).value).toBe(3);
73 expect((await quotas(ro))["apps"]).toBe(3);
74 expect((await put(admin, "apps", null)).status).toBe(200);
75 expect((await quotas(ro))["apps"]).toBeNull();
76 await put(admin, "runs", "7");
77 expect((await quotas(ro))["runs"]).toBe(7);
78 expect((await put(admin, "runs", "")).status).toBe(200);
79 expect((await quotas(ro))["runs"]).toBeNull();
80 });
81
82 test("refuses an unknown key, a bad value, a ro token and a member", async () => {
83 const unknown = await put(admin, "gold", 1);
84 expect(unknown.status).toBe(422);
85 expect(((await unknown.json()) as { error: string }).error).toBe("no_such_quota");
86 for (const bad of [0, -1, 1.5, "abc", true]) {
87 const res = await put(admin, "checks", bad);
88 expect(res.status).toBe(422);
89 expect(((await res.json()) as { error: string }).error).toBe("invalid_quota");
90 }
91 expect((await put(ro, "checks", 2)).status).toBe(403);
92 expect((await quotas(admin))["checks"]).toBeNull();
93 // A member without `administer` meets the shared admin gate (403) before the handler's 404.
94 expect((await get(member)).status).toBe(403);
95 expect((await put(member, "checks", 2)).status).toBe(403);
96 expect((await quotas(admin))["checks"]).toBeNull();
97 });
98
99 test("a body without `value` is a 422 and leaves the quota as it was", async () => {
100 expect((await put(admin, "previews", 4)).status).toBe(200);
101 const raw = (body: string) =>
102 fetch(`${servers.url}/api/v1/admin/quotas/previews`, {
103 method: "PUT",
104 headers: { authorization: `Bearer ${admin}`, "content-type": "application/json" },
105 body,
106 });
107 for (const body of ["{}", '{"valeu":3}', ""]) {
108 const res = await raw(body);
109 expect(res.status).toBe(422);
110 expect(((await res.json()) as { error: string }).error).toBe("invalid_quota");
111 }
112 expect((await quotas(ro))["previews"]).toBe(4);
113 expect((await put(admin, "previews", null)).status).toBe(200);
114 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs