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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7907-get-api-v1-admin-quotas-and-put-api-v1-a → main 2 commits fork
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 filesrc/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"; | ||
| 33 | 33 | export { route as getApiV1AdminInvites } from "./getApiV1AdminInvites.ts"; |
| 34 | 34 | export { route as getApiV1AdminLimits } from "./getApiV1AdminLimits.ts"; |
| 35 | 35 | export { route as getApiV1AdminPeople } from "./getApiV1AdminPeople.ts"; |
| 36 | export { route as getApiV1AdminQuotas } from "./getApiV1AdminQuotas.ts"; | |
| 36 | 37 | export { route as getApiV1AdminSettings } from "./getApiV1AdminSettings.ts"; |
| 37 | 38 | export { route as getApiV1AdminTeams } from "./getApiV1AdminTeams.ts"; |
| 38 | 39 | export { route as getApiV1Agents } from "./getApiV1Agents.ts"; |
| @@ -273,6 +274,7 @@ export { route as postApiV1Users } from "./postApiV1Users.ts"; | ||
| 273 | 274 | export { route as postMcp } from "./postMcp.ts"; |
| 274 | 275 | export { route as putApiV1AdminCapacityKey } from "./putApiV1AdminCapacityKey.ts"; |
| 275 | 276 | export { route as putApiV1AdminLimitsKey } from "./putApiV1AdminLimitsKey.ts"; |
| 277 | export { route as putApiV1AdminQuotasKey } from "./putApiV1AdminQuotasKey.ts"; | |
| 276 | 278 | export { route as putApiV1AdminTeamsSlug } from "./putApiV1AdminTeamsSlug.ts"; |
| 277 | 279 | export { route as putApiV1BoardsSlug } from "./putApiV1BoardsSlug.ts"; |
| 278 | 280 | 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 { | ||
| 105 | 105 | ); |
| 106 | 106 | }); |
| 107 | 107 | |
| 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 | ||
| 108 | 130 | // Crew teams (`core/teams.ts`): a forge admin who is a person; the page's door. |
| 109 | 131 | router.add("GET", "/api/v1/admin/teams", (ctx) => { |
| 110 | 132 | 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 | }); |
new above
offline