Public: anyone can read this; the crew can change it.
Per-account quotas exist as settings with no limit by default: the owner sets repositories and apps from Settings and the API, audited, nothing enforced yet #2100
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2092-per-account-quotas-exist-as-settings-wit → main 4 commits fork 5 threads · 5 unresolved
README.md +2
| @@ -1097,6 +1097,8 @@ While the default branch's own last check is red, the forge holds its pulls' che | ||
| 1097 | 1097 | |
| 1098 | 1098 | Every limit the owner can hit is declared in `src/settings/limits.ts` with its default, bounds and reason, and changed live by a forge admin at `/admin/limits`, `PUT /api/v1/admin/limits/:key` or the `limits` MCP tool (never by an agent). Each change is kept with who made it. |
| 1099 | 1099 | |
| 1100 | Per-account quotas (`src/settings/quotas.ts`) — how many `repositories` and `apps` one account may own — are set the same way: **Account quotas** on `/admin/limits`, `GET`/`PATCH /api/v1/admin/settings` (`{"quotas": {"repositories": 5, "apps": null}}`) or the `quotas` MCP tool (dry by default, admin scope, never an agent). A value is a positive whole number, or empty/`null` for no limit, which is the default; anything else is a 422. Every change is kept with who made it and listed beside the form. Nothing enforces a quota yet: no create path reads one. | |
| 1101 | ||
| 1100 | 1102 | ## Choosing among variants |
| 1101 | 1103 | |
| 1102 | 1104 | A tell with `variants: n` starts n builders whose pulls share a variant group. |
src/actions/quotas.ts added +53
| @@ -0,0 +1,53 @@ | ||
| 1 | /** | |
| 2 | * Per-account quotas (`settings/quotas.ts`), for a forge admin who is a person. | |
| 3 | * Everyone else, an agent included, is told `not_found`. | |
| 4 | */ | |
| 5 | ||
| 6 | import type { App } from "../app.ts"; | |
| 7 | import { | |
| 8 | getQuotas, | |
| 9 | isQuotaKey, | |
| 10 | parseQuota, | |
| 11 | quotaChanges, | |
| 12 | setQuota, | |
| 13 | type QuotaChange, | |
| 14 | type QuotaKey, | |
| 15 | type Quotas, | |
| 16 | } from "../settings/quotas.ts"; | |
| 17 | import type { User } from "../core/users.ts"; | |
| 18 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 19 | ||
| 20 | export type QuotaError = "not_found" | "invalid_quota" | "no_such_quota"; | |
| 21 | ||
| 22 | function admin(actor: User | undefined): Result<User, "not_found"> { | |
| 23 | return actor?.isAdmin === true && actor.kind !== "agent" ? ok(actor) : err("not_found"); | |
| 24 | } | |
| 25 | ||
| 26 | export function readQuotas( | |
| 27 | app: App, | |
| 28 | actor: User | undefined, | |
| 29 | ): Result<{ quotas: Quotas; changes: QuotaChange[] }, "not_found"> { | |
| 30 | const who = admin(actor); | |
| 31 | return who.ok ? ok({ quotas: getQuotas(app.db), changes: quotaChanges(app.db) }) : who; | |
| 32 | } | |
| 33 | ||
| 34 | /** Apply `changes` (key → raw value) all or nothing: one bad value changes none. */ | |
| 35 | export function changeQuotas( | |
| 36 | app: App, | |
| 37 | actor: User | undefined, | |
| 38 | changes: Record<string, unknown>, | |
| 39 | ): Result<Quotas, QuotaError> { | |
| 40 | const who = admin(actor); | |
| 41 | if (!who.ok) return who; | |
| 42 | const parsed: [QuotaKey, number | null][] = []; | |
| 43 | for (const [key, raw] of Object.entries(changes)) { | |
| 44 | if (!isQuotaKey(key)) return err("no_such_quota"); | |
| 45 | const value = parseQuota(raw); | |
| 46 | if (!value.ok) return err(value.error); | |
| 47 | parsed.push([key, value.value]); | |
| 48 | } | |
| 49 | app.db.transaction(() => { | |
| 50 | for (const [key, value] of parsed) setQuota(app.db, key, value, who.value.id); | |
| 51 | })(); | |
| 52 | return ok(getQuotas(app.db)); | |
| 53 | } |
src/http/api.ts +29
| @@ -22,6 +22,7 @@ import { askCheck } from "../checks/again.ts"; | ||
| 22 | 22 | import { mayView, mayViewId, sealedScope } from "../core/sealed.ts"; |
| 23 | 23 | import { changeCapacity, listCapacity } from "../actions/capacity.ts"; |
| 24 | 24 | import { changeLimit, listLimits } from "../actions/limits.ts"; |
| 25 | import { changeQuotas, readQuotas } from "../actions/quotas.ts"; | |
| 25 | 26 | import { |
| 26 | 27 | accountSignIns, |
| 27 | 28 | endAccountSession, |
| @@ -1436,6 +1437,34 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1436 | 1437 | ); |
| 1437 | 1438 | }); |
| 1438 | 1439 | |
| 1440 | // Per-account quotas (`core/quotas.ts`): null is no limit. Nothing | |
This comment still says |
||
| 1441 | // enforces them yet. | |
| 1442 | router.add("GET", "/api/v1/admin/settings", (ctx) => { | |
| 1443 | const denied = accountAdmin(ctx, "ro"); | |
| 1444 | if (denied) return denied; | |
| 1445 | const read = readQuotas(ctx.app, ctx.auth.user); | |
| 1446 | return read.ok ? json(read.value) : notFound(); | |
| 1447 | }); | |
| 1448 | ||
| 1449 | router.add("PATCH", "/api/v1/admin/settings", async (ctx) => { | |
| 1450 | const denied = accountAdmin(ctx, "admin"); | |
| 1451 | if (denied) return denied; | |
| 1452 | const input = await body(ctx.request); | |
| 1453 | const quotas = input.quotas; | |
| 1454 | if (typeof quotas !== "object" || quotas === null || Array.isArray(quotas)) | |
| 1455 | return apiError("invalid_body", 422, "send {\"quotas\": {\"repositories\": 5, \"apps\": null}}"); | |
| 1456 | const changed = changeQuotas(ctx.app, ctx.auth.user, quotas as Record<string, unknown>); | |
| 1457 | if (changed.ok) return json({ quotas: changed.value }); | |
| 1458 | if (changed.error === "not_found") return notFound(); | |
| 1459 | return apiError( | |
| 1460 | changed.error, | |
| 1461 | 422, | |
| 1462 | changed.error === "no_such_quota" | |
| 1463 | ? "no such quota" | |
| 1464 | : "a quota is a positive whole number, or null for no limit", | |
| 1465 | ); | |
| 1466 | }); | |
| 1467 | ||
| 1439 | 1468 | router.add("GET", "/api/v1/admin/accounts", (ctx) => { |
| 1440 | 1469 | const denied = accountAdmin(ctx, "ro"); |
| 1441 | 1470 | if (denied) return denied; |
src/http/mcp.ts +40
| @@ -26,6 +26,8 @@ import { readPipeline, verdict } from "../flow/pipeline.ts"; | ||
| 26 | 26 | import { readQueues } from "../flow/queue.ts"; |
| 27 | 27 | import { readThroughput, stageOut, throughputSays } from "../flow/throughput.ts"; |
| 28 | 28 | import { changeLimit, listLimits } from "../actions/limits.ts"; |
| 29 | import { changeQuotas, readQuotas } from "../actions/quotas.ts"; | |
| 30 | import { QUOTA_KEYS } from "../settings/quotas.ts"; | |
| 29 | 31 | import { LIMIT_KEYS } from "../settings/limits.ts"; |
| 30 | 32 | import { relationOf, takeUpdates } from "../actions/forks.ts"; |
| 31 | 33 | import { watchMilestone } from "../actions/watches.ts"; |
| @@ -2133,6 +2135,44 @@ const TOOLS: readonly Tool[] = [ | ||
| 2133 | 2135 | }); |
| 2134 | 2136 | }, |
| 2135 | 2137 | }, |
| 2138 | { | |
| 2139 | name: "quotas", | |
| 2140 | description: | |
| 2141 | "What one account may own: per-account quotas for repositories and apps, each a positive whole number or no limit (null, the default). Nothing enforces them yet. Without `key`, each quota and the recent changes. With `key` and `value` (\"\" clears it), the change it would make; with dryRun false, made, by a forge admin with an admin-scope token, never an agent. The same as /admin/limits (Account quotas) and /api/v1/admin/settings.", | |
| 2142 | inputSchema: { | |
| 2143 | type: "object", | |
| 2144 | properties: { | |
| 2145 | key: { type: "string", enum: [...QUOTA_KEYS] }, | |
| 2146 | value: { type: "string", maxLength: 16 }, | |
| 2147 | dryRun: { type: "boolean", description: "Plan only (the default). Send false to change it." }, | |
| 2148 | }, | |
| 2149 | }, | |
| 2150 | writes: true, | |
| 2151 | dryByDefault: true, | |
| 2152 | run(ctx, user, args) { | |
| 2153 | const read = readQuotas(ctx.app, user); | |
| 2154 | if (!read.ok) return bad("not_found", "no such tool here"); | |
| 2155 | const key = typeof args["key"] === "string" ? args["key"] : undefined; | |
| 2156 | if (key === undefined) return good(read.value); | |
| 2157 | const value = typeof args["value"] === "string" ? args["value"] : ""; | |
| 2158 | if (!(QUOTA_KEYS as readonly string[]).includes(key)) | |
| 2159 | return bad("no_such_quota", `no quota ${key}`); | |
| 2160 | const now = read.value.quotas[key as (typeof QUOTA_KEYS)[number]]; | |
| 2161 | if (args["dryRun"] !== false) | |
The dry run doesn't validate |
||
| 2162 | return good({ | |
| 2163 | says: `Would set ${key} from ${now ?? "no limit"} to ${value || "no limit"}. Send dryRun: false to change it.`, | |
| 2164 | }); | |
| 2165 | if (!scopeAllows(ctx.auth.scope ?? "ro", "admin")) | |
| 2166 | return bad("forbidden", "changing the forge's quotas needs an admin-scope token"); | |
| 2167 | const changed = changeQuotas(ctx.app, user, { [key]: value }); | |
| 2168 | if (!changed.ok) | |
| 2169 | return bad(changed.error, "a quota is a positive whole number, or empty for no limit"); | |
| 2170 | return good({ | |
| 2171 | says: `${key}: ${now ?? "no limit"} → ${changed.value[key as (typeof QUOTA_KEYS)[number]] ?? "no limit"}.`, | |
| 2172 | quotas: changed.value, | |
| 2173 | }); | |
| 2174 | }, | |
| 2175 | }, | |
| 2136 | 2176 | { |
| 2137 | 2177 | name: "boards", |
| 2138 | 2178 | description: |
src/http/server.ts +2 −1
| @@ -12,7 +12,7 @@ import { accessPage } from "../web/pages/access.ts"; | ||
| 12 | 12 | import { capacityPage, capacitySet } from "../web/pages/capacity.ts"; |
| 13 | 13 | import { flowPage } from "../web/pages/flow.ts"; |
| 14 | 14 | import { resiliencePage } from "../web/pages/resilience.ts"; |
| 15 | import { limitsPage, limitsSet } from "../web/pages/admin-limits.ts"; | |
| 15 | import { limitsPage, limitsSet, quotasSet } from "../web/pages/admin-limits.ts"; | |
| 16 | 16 | import { boardsPage } from "../web/pages/boards.ts"; |
| 17 | 17 | import { boardNew, boardPin, boardPost } from "../web/pages/boards-edit.ts"; |
| 18 | 18 | import { boardLive, boardShow, boardsList } from "../web/pages/boards-view.ts"; |
| @@ -749,6 +749,7 @@ export function buildRouter(): Router<Handler> { | ||
| 749 | 749 | router.add("POST", "/boards/:slug/:op", boardPost); |
| 750 | 750 | router.add("GET", "/admin/limits", limitsPage); |
| 751 | 751 | router.add("POST", "/admin/limits", limitsSet); |
| 752 | router.add("POST", "/admin/quotas", quotasSet); | |
| 752 | 753 | router.add("GET", "/settings/backups", backupsPage); |
| 753 | 754 | router.add("POST", "/settings/backups/now", backupsNow); |
| 754 | 755 | router.add("POST", "/settings/backups/drill", backupsDrill); |
src/settings/limits.ts +39 −15
| @@ -211,6 +211,37 @@ export type LimitError = { | ||
| 211 | 211 | readonly message: string; |
| 212 | 212 | }; |
| 213 | 213 | |
| 214 | /** | |
| 215 | * The one write path for a forge setting kept under its own `forge_setting` | |
| 216 | * key: set it (`null` removes it) and keep the change — who, when, from what | |
| 217 | * to what — in `forge_setting_change`, in one transaction. `from` and `to` | |
| 218 | * are the words kept for the change, which for a removed value is whatever | |
| 219 | * stands in its place. | |
| 220 | */ | |
| 221 | export function writeSetting( | |
| 222 | db: Database, | |
| 223 | key: string, | |
| 224 | value: string | null, | |
| 225 | from: string, | |
| 226 | to: string, | |
| 227 | actorId: string, | |
| 228 | ): void { | |
| 229 | const at = new Date().toISOString(); | |
| 230 | db.transaction(() => { | |
| 231 | if (value === null) db.query("DELETE FROM forge_setting WHERE key = ?1").run(key); | |
| 232 | else | |
| 233 | db.query( | |
| 234 | `INSERT INTO forge_setting (key, value, updated_by, updated_at) VALUES (?1, ?2, ?3, ?4) | |
| 235 | ON CONFLICT (key) DO UPDATE SET value = excluded.value, | |
| 236 | updated_by = excluded.updated_by, updated_at = excluded.updated_at`, | |
| 237 | ).run(key, value, actorId, at); | |
| 238 | db.query( | |
| 239 | `INSERT INTO forge_setting_change (key, from_value, to_value, actor_id, at) | |
| 240 | VALUES (?1, ?2, ?3, ?4, ?5)`, | |
| 241 | ).run(key, from, to, actorId, at); | |
| 242 | })(); | |
| 243 | } | |
| 244 | ||
| 214 | 245 | /** |
| 215 | 246 | * Set `key` to `value` (`null` goes back to the default) and keep the |
| 216 | 247 | * change. A value outside the bounds is refused with the bounds named. |
| @@ -229,21 +260,14 @@ export function setLimit( | ||
| 229 | 260 | setCapacity(db, cap, value === null ? null : String(value), actorId); |
| 230 | 261 | return ok({ from, to: limitValue(db, key) }); |
| 231 | 262 | } |
| 232 | const at = new Date().toISOString(); | |
| 233 | db.transaction(() => { | |
| 234 | if (value === null) | |
| 235 | db.query("DELETE FROM forge_setting WHERE key = ?1").run(storeKey(key)); | |
| 236 | else | |
| 237 | db.query( | |
| 238 | `INSERT INTO forge_setting (key, value, updated_by, updated_at) VALUES (?1, ?2, ?3, ?4) | |
| 239 | ON CONFLICT (key) DO UPDATE SET value = excluded.value, | |
| 240 | updated_by = excluded.updated_by, updated_at = excluded.updated_at`, | |
| 241 | ).run(storeKey(key), String(value), actorId, at); | |
| 242 | db.query( | |
| 243 | `INSERT INTO forge_setting_change (key, from_value, to_value, actor_id, at) | |
| 244 | VALUES (?1, ?2, ?3, ?4, ?5)`, | |
| 245 | ).run(storeKey(key), String(from), String(limitValue(db, key)), actorId, at); | |
| 246 | })(); | |
| 263 | writeSetting( | |
| 264 | db, | |
| 265 | storeKey(key), | |
| 266 | value === null ? null : String(value), | |
| 267 | String(from), | |
| 268 | String(value ?? LIMITS[key].default), | |
| 269 | actorId, | |
| 270 | ); | |
| 247 | 271 | return ok({ from, to: limitValue(db, key) }); |
| 248 | 272 | } |
| 249 | 273 |
src/settings/quotas.ts added +100
| @@ -0,0 +1,100 @@ | ||
| 1 | /** | |
| 2 | * Per-account quotas — how much one account may own. Stored in | |
| 3 | * `forge_setting` under `quota.<key>`, and every change is kept in | |
| 4 | * `forge_setting_change` (who, when, from what to what). The default for | |
| 5 | * each is no limit (`null`), and this part enforces nothing: no create path | |
| 6 | * asks yet. Room for more keys is the `QUOTA_KEYS` list. | |
| 7 | */ | |
| 8 | ||
| 9 | import type { Database } from "bun:sqlite"; | |
| 10 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 11 | import { writeSetting } from "./limits.ts"; | |
| 12 | ||
| 13 | export const QUOTA_KEYS = ["repositories", "apps"] as const; | |
| 14 | export type QuotaKey = (typeof QUOTA_KEYS)[number]; | |
| 15 | export type Quotas = Record<QuotaKey, number | null>; | |
| 16 | ||
| 17 | const MAX = 1_000_000_000; | |
| 18 | ||
| 19 | export function isQuotaKey(key: string): key is QuotaKey { | |
| 20 | return (QUOTA_KEYS as readonly string[]).includes(key); | |
| 21 | } | |
| 22 | ||
| 23 | const storeKey = (key: QuotaKey): string => `quota.${key}`; | |
| 24 | ||
| 25 | /** A limit from untrusted input: a positive integer, or empty/null for none. */ | |
| 26 | export function parseQuota(raw: unknown): Result<number | null, "invalid_quota"> { | |
| 27 | if (raw === null || raw === undefined) return ok(null); | |
| 28 | if (typeof raw === "number") | |
| 29 | return Number.isInteger(raw) && raw >= 1 && raw <= MAX ? ok(raw) : err("invalid_quota"); | |
| 30 | if (typeof raw !== "string") return err("invalid_quota"); | |
| 31 | const s = raw.trim(); | |
| 32 | if (s === "") return ok(null); | |
| 33 | if (!/^\d{1,10}$/.test(s)) return err("invalid_quota"); | |
| 34 | const n = Number(s); | |
| 35 | return n >= 1 && n <= MAX ? ok(n) : err("invalid_quota"); | |
| 36 | } | |
| 37 | ||
| 38 | export function getQuota(db: Database, key: QuotaKey): number | null { | |
| 39 | const row = db | |
| 40 | .query<{ value: string }, [string]>("SELECT value FROM forge_setting WHERE key = ?1") | |
| 41 | .get(storeKey(key)); | |
| 42 | if (row === null) return null; | |
| 43 | const parsed = parseQuota(row.value); | |
| 44 | return parsed.ok ? parsed.value : null; | |
| 45 | } | |
| 46 | ||
| 47 | export function getQuotas(db: Database): Quotas { | |
| 48 | return { repositories: getQuota(db, "repositories"), apps: getQuota(db, "apps") }; | |
The keys are hardcoded here, even though the header says |
||
| 49 | } | |
| 50 | ||
| 51 | /** Set `key` (`null` clears it) and keep the change. */ | |
| 52 | export function setQuota( | |
| 53 | db: Database, | |
| 54 | key: QuotaKey, | |
| 55 | value: number | null, | |
| 56 | actorId: string, | |
| 57 | ): void { | |
| 58 | const from = getQuota(db, key); | |
| 59 | if (from === value) return; | |
| 60 | writeSetting( | |
| 61 | db, | |
| 62 | storeKey(key), | |
| 63 | value === null ? null : String(value), | |
| 64 | from === null ? "" : String(from), | |
| 65 | value === null ? "" : String(value), | |
| 66 | actorId, | |
| 67 | ); | |
| 68 | } | |
| 69 | ||
| 70 | export interface QuotaChange { | |
| 71 | readonly key: QuotaKey; | |
| 72 | readonly from: string; | |
| 73 | readonly to: string; | |
| 74 | readonly actorName: string; | |
| 75 | readonly at: string; | |
| 76 | } | |
| 77 | ||
| 78 | /** The most recent quota changes, newest first. An empty value is no limit. */ | |
| 79 | export function quotaChanges(db: Database, limit = 20): QuotaChange[] { | |
| 80 | return db | |
| 81 | .query< | |
| 82 | { key: string; from_value: string; to_value: string; username: string | null; at: string }, | |
| 83 | [] | |
| 84 | >( | |
| 85 | `SELECT c.key, c.from_value, c.to_value, u.username, c.at | |
| 86 | FROM forge_setting_change c LEFT JOIN user u ON u.id = c.actor_id | |
| 87 | WHERE c.key LIKE 'quota.%' | |
| 88 | ORDER BY c.at DESC, c.rowid DESC | |
| 89 | LIMIT ${Math.trunc(limit)}`, | |
| 90 | ) | |
| 91 | .all() | |
| 92 | .filter((r) => isQuotaKey(r.key.slice(6))) | |
| 93 | .map((r) => ({ | |
| 94 | key: r.key.slice(6) as QuotaKey, | |
| 95 | from: r.from_value, | |
| 96 | to: r.to_value, | |
| 97 | actorName: r.username ?? "", | |
| 98 | at: r.at, | |
| 99 | })); | |
| 100 | } |
src/web/pages/admin-limits.ts +65 −1
| @@ -6,6 +6,8 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import { changeLimit, listLimits } from "../../actions/limits.ts"; |
| 9 | import { changeQuotas, readQuotas } from "../../actions/quotas.ts"; | |
| 10 | import { QUOTA_KEYS } from "../../settings/quotas.ts"; | |
| 9 | 11 | import type { Ctx } from "../../http/context.ts"; |
| 10 | 12 | import { notFoundPage, settingsLayout } from "../chrome.ts"; |
| 11 | 13 | import { field, readForm } from "../form.ts"; |
| @@ -69,7 +71,48 @@ export function limitsPage(ctx: Ctx): Response { | ||
| 69 | 71 | meta: html`${c.actorName === "" ? "someone" : c.actorName} · ${time(c.at)}`, |
| 70 | 72 | }), |
| 71 | 73 | ); |
| 72 | const body = html`${card( | |
| 74 | const quotas = readQuotas(ctx.app, ctx.auth.user); | |
The card shows the current values but not who changed them or when. The limits history below filters to LIMIT_KEYS, so a quota change is kept in the database and shown nowhere. List the recent |
||
| 75 | const quotaHistory = (quotas.ok ? quotas.value.changes : []).map((c) => | |
| 76 | row({ | |
| 77 | lead: icon("history"), | |
| 78 | title: html`<span class="mono">${c.key}</span> | |
| 79 | <span class="mono">${c.from || "no limit"}</span> → | |
| 80 | <span class="mono">${c.to || "no limit"}</span>`, | |
| 81 | meta: html`${c.actorName === "" ? "someone" : c.actorName} · ${time(c.at)}`, | |
| 82 | }), | |
| 83 | ); | |
| 84 | const quotaCard = card( | |
| 85 | html`<div class="card-body"> | |
| 86 | <p class="muted small"> | |
| 87 | What one account may own. Empty is no limit, and nothing enforces | |
| 88 | these yet. | |
| 89 | </p> | |
| 90 | <form class="row-actions" method="post" action="/admin/quotas"> | |
| 91 | ${QUOTA_KEYS.map( | |
| 92 | (k) => html`<label class="small">${k} | |
| 93 | <input | |
| 94 | class="input mono" | |
| 95 | type="number" | |
| 96 | name="${k}" | |
| 97 | min="1" | |
| 98 | step="1" | |
| 99 | value="${quotas.ok ? String(quotas.value.quotas[k] ?? "") : ""}" | |
| 100 | /></label>`, | |
| 101 | )} | |
| 102 | ${btn("Save", { size: "sm", variant: "primary", label: "Save quotas" })} | |
| 103 | </form> | |
| 104 | </div> | |
| 105 | ${when( | |
| 106 | quotaHistory.length > 0, | |
| 107 | () => html`<ul class="list">${quotaHistory}</ul>`, | |
| 108 | )}`, | |
| 109 | { | |
| 110 | head: html`${icon("activity")} | |
| 111 | <h2>Account quotas</h2>`, | |
| 112 | pad: false, | |
| 113 | }, | |
| 114 | ); | |
| 115 | const body = html`${quotaCard}${card( | |
| 73 | 116 | html`<div class="card-body"> |
| 74 | 117 | <p class="muted small"> |
| 75 | 118 | Every limit you can hit, and why it is there. A change governs |
| @@ -147,3 +190,24 @@ export async function limitsSet(ctx: Ctx): Promise<Response> { | ||
| 147 | 190 | }, |
| 148 | 191 | ); |
| 149 | 192 | } |
| 193 | ||
| 194 | // POST /admin/quotas | |
| 195 | export async function quotasSet(ctx: Ctx): Promise<Response> { | |
| 196 | if (ctx.auth.user?.isAdmin !== true || ctx.auth.user.kind === "agent") | |
| 197 | return notFoundPage(ctx); | |
| 198 | const form = await readForm(ctx, "admin"); | |
| 199 | if (form instanceof Response) return form; | |
| 200 | const changed = changeQuotas( | |
| 201 | ctx.app, | |
| 202 | ctx.auth.user, | |
| 203 | Object.fromEntries(QUOTA_KEYS.map((k) => [k, field(form, k)])), | |
| 204 | ); | |
| 205 | if (!changed.ok && changed.error === "not_found") return notFoundPage(ctx); | |
| 206 | return flashTo( | |
| 207 | ctx, | |
| 208 | "/admin/limits", | |
| 209 | changed.ok | |
| 210 | ? { message: "Quotas saved." } | |
| 211 | : { tone: "danger", message: "A quota is a positive whole number, or empty for no limit." }, | |
| 212 | ); | |
| 213 | } |
test/mcp.test.ts +1
| @@ -210,6 +210,7 @@ describe("the forge over MCP", () => { | ||
| 210 | 210 | "capacity", |
| 211 | 211 | "flow", |
| 212 | 212 | "limits", |
| 213 | "quotas", | |
| 213 | 214 | "boards", |
| 214 | 215 | "board_write", |
| 215 | 216 | "board_delete", |
test/quotas.test.ts added +180
| @@ -0,0 +1,180 @@ | ||
| 1 | /** | |
| 2 | * Per-account quotas (#2092): unset by default, an admin sets and clears | |
| 3 | * each from the API or the page, a non-admin is refused, a bad value is a | |
| 4 | * 422, and every change is kept. Nothing enforces them yet. | |
| 5 | */ | |
| 6 | ||
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { getQuotas, quotaChanges } from "../src/settings/quotas.ts"; | |
| 13 | import { createToken } from "../src/core/tokens.ts"; | |
| 14 | import { createUser, type User } from "../src/core/users.ts"; | |
| 15 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 16 | ||
| 17 | describe("quotas", () => { | |
| 18 | let dataDir: string; | |
| 19 | let app: App; | |
| 20 | let servers: Servers; | |
| 21 | let alice: User; | |
| 22 | let bob: User; | |
| 23 | ||
| 24 | beforeAll(async () => { | |
| 25 | dataDir = await mkdtemp(join(tmpdir(), "forge-quotas-")); | |
| 26 | app = createApp({ dataDir, port: 0 }); | |
| 27 | servers = await serve(app); | |
| 28 | for (const username of ["alice", "bob"]) { | |
| 29 | const made = await createUser(app.db, { | |
| 30 | trusted: true, | |
| 31 | username, | |
| 32 | email: `${username}@forge.local`, | |
| 33 | password: "a-good-passphrase", | |
| 34 | adminIfFirst: username === "alice", | |
| 35 | }); | |
| 36 | if (!made.ok) throw new Error(made.error); | |
| 37 | if (username === "alice") alice = made.value; | |
| 38 | else bob = made.value; | |
| 39 | } | |
| 40 | }); | |
| 41 | afterAll(async () => { | |
| 42 | await servers.stop(); | |
| 43 | await rm(dataDir, { recursive: true, force: true }); | |
| 44 | }); | |
| 45 | ||
| 46 | const api = (token: string, method: string, body?: unknown) => | |
| 47 | fetch(`${servers.url}/api/v1/admin/settings`, { | |
| 48 | method, | |
| 49 | headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, | |
| 50 | ...(body === undefined ? {} : { body: JSON.stringify(body) }), | |
| 51 | }); | |
| 52 | ||
| 53 | test("limits start unset, an admin sets one and clears it, each change is kept", async () => { | |
| 54 | const t = createToken(app.db, alice.id, "t", "admin").token; | |
| 55 | expect(getQuotas(app.db)).toEqual({ repositories: null, apps: null }); | |
| 56 | expect(await (await api(t, "GET")).json()).toMatchObject({ | |
| 57 | quotas: { repositories: null, apps: null }, | |
| 58 | changes: [], | |
| 59 | }); | |
| 60 | ||
| 61 | const set = await api(t, "PATCH", { quotas: { repositories: 5, apps: "3" } }); | |
| 62 | expect(set.status).toBe(200); | |
| 63 | expect(getQuotas(app.db)).toEqual({ repositories: 5, apps: 3 }); | |
| 64 | ||
| 65 | const cleared = await api(t, "PATCH", { quotas: { repositories: null, apps: "" } }); | |
| 66 | expect(cleared.status).toBe(200); | |
| 67 | expect(getQuotas(app.db)).toEqual({ repositories: null, apps: null }); | |
| 68 | ||
| 69 | const changes = app.db | |
| 70 | .query<{ n: number }, []>( | |
| 71 | "SELECT count(*) AS n FROM forge_setting_change WHERE key LIKE 'quota.%'", | |
| 72 | ) | |
| 73 | .get(); | |
| 74 | expect(changes?.n).toBe(4); | |
| 75 | // The audit can be read: through the API, newest first, with who. | |
| 76 | const read = (await (await api(t, "GET")).json()) as any; | |
| 77 | expect(read.changes).toHaveLength(4); | |
| 78 | expect(read.changes[0]).toMatchObject({ actorName: "alice" }); | |
| 79 | expect(quotaChanges(app.db).map((c) => c.key).sort()).toEqual([ | |
| 80 | "apps", | |
| 81 | "apps", | |
| 82 | "repositories", | |
| 83 | "repositories", | |
| 84 | ]); | |
| 85 | }); | |
| 86 | ||
| 87 | const rpc = async (token: string, name: string, args: unknown = {}) => { | |
| 88 | const res = await fetch(`${servers.url}/mcp`, { | |
| 89 | method: "POST", | |
| 90 | headers: { | |
| 91 | "content-type": "application/json", | |
| 92 | accept: "application/json, text/event-stream", | |
| 93 | authorization: `Bearer ${token}`, | |
| 94 | }, | |
| 95 | body: JSON.stringify({ | |
| 96 | jsonrpc: "2.0", | |
| 97 | id: 1, | |
| 98 | method: "tools/call", | |
| 99 | params: { name, arguments: args }, | |
| 100 | }), | |
| 101 | }); | |
| 102 | const body = (await res.json()) as any; | |
| 103 | const text: string = body.result.content[0].text; | |
| 104 | return body.result.isError === true ? { error: text } : { value: JSON.parse(text) }; | |
| 105 | }; | |
| 106 | ||
| 107 | test("the quotas tool: dry by default, an admin scope to change, never a non-admin", async () => { | |
| 108 | const admin = createToken(app.db, alice.id, "t", "admin").token; | |
| 109 | const ro = createToken(app.db, alice.id, "t", "ro").token; | |
| 110 | const bobAdmin = createToken(app.db, bob.id, "t", "admin").token; | |
| 111 | expect((await rpc(bobAdmin, "quotas")).error).toContain("no such tool"); | |
| 112 | ||
| 113 | expect((await rpc(ro, "quotas")).value.quotas).toEqual({ repositories: null, apps: null }); | |
| 114 | const dry = await rpc(admin, "quotas", { key: "apps", value: "9" }); | |
| 115 | expect(dry.value.says).toContain("Would set"); | |
| 116 | expect(getQuotas(app.db).apps).toBeNull(); | |
| 117 | ||
| 118 | const refused = await rpc(ro, "quotas", { key: "apps", value: "9", dryRun: false }); | |
| 119 | expect(refused.error).toContain("forbidden"); | |
| 120 | expect(getQuotas(app.db).apps).toBeNull(); | |
| 121 | ||
| 122 | expect((await rpc(admin, "quotas", { key: "apps", value: "0", dryRun: false })).error).toContain( | |
| 123 | "invalid_quota", | |
| 124 | ); | |
| 125 | const done = await rpc(admin, "quotas", { key: "apps", value: "9", dryRun: false }); | |
| 126 | expect(done.value.quotas.apps).toBe(9); | |
| 127 | expect(getQuotas(app.db).apps).toBe(9); | |
| 128 | await rpc(admin, "quotas", { key: "apps", value: "", dryRun: false }); | |
| 129 | expect(getQuotas(app.db).apps).toBeNull(); | |
| 130 | expect((await rpc(admin, "quotas")).value.changes[0]).toMatchObject({ key: "apps", to: "" }); | |
| 131 | }); | |
| 132 | ||
| 133 | test("a non-admin is refused", async () => { | |
| 134 | const t = createToken(app.db, bob.id, "t", "admin").token; | |
| 135 | expect((await api(t, "GET")).status).toBe(404); | |
| 136 | expect((await api(t, "PATCH", { quotas: { apps: 2 } })).status).toBe(404); | |
| 137 | expect(getQuotas(app.db).apps).toBeNull(); | |
| 138 | }); | |
| 139 | ||
| 140 | test("a bad value is a 422 and changes nothing", async () => { | |
| 141 | const t = createToken(app.db, alice.id, "t", "admin").token; | |
| 142 | for (const bad of [0, -1, 1.5, "abc", "1e3", true, [], 2_000_000_000]) { | |
| 143 | const res = await api(t, "PATCH", { quotas: { apps: 4, repositories: bad } }); | |
| 144 | expect(res.status).toBe(422); | |
| 145 | } | |
| 146 | expect((await api(t, "PATCH", { quotas: { nope: 1 } })).status).toBe(422); | |
| 147 | expect((await api(t, "PATCH", {})).status).toBe(422); | |
| 148 | expect(getQuotas(app.db)).toEqual({ repositories: null, apps: null }); | |
| 149 | }); | |
| 150 | ||
| 151 | test("the admin page form sets a quota and refuses a bad one", async () => { | |
| 152 | const res = await fetch(`${servers.url}/signin`, { | |
| 153 | method: "POST", | |
| 154 | redirect: "manual", | |
| 155 | headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url }, | |
| 156 | body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }), | |
| 157 | }); | |
| 158 | const cookie = (res.headers.get("set-cookie") ?? "").split(";")[0] ?? ""; | |
| 159 | const post = (form: Record<string, string>) => | |
| 160 | fetch(`${servers.url}/admin/quotas`, { | |
| 161 | method: "POST", | |
| 162 | redirect: "manual", | |
| 163 | headers: { | |
| 164 | cookie, | |
| 165 | "content-type": "application/x-www-form-urlencoded", | |
| 166 | origin: servers.url, | |
| 167 | }, | |
| 168 | body: new URLSearchParams(form), | |
| 169 | }); | |
| 170 | await post({ repositories: "7", apps: "" }); | |
| 171 | expect(getQuotas(app.db)).toEqual({ repositories: 7, apps: null }); | |
| 172 | await post({ repositories: "0", apps: "2" }); | |
| 173 | expect(getQuotas(app.db)).toEqual({ repositories: 7, apps: null }); | |
| 174 | const page = await fetch(`${servers.url}/admin/limits`, { headers: { cookie } }); | |
| 175 | const text = await page.text(); | |
| 176 | expect(text).toContain('name="repositories"'); | |
| 177 | expect(text).toContain("alice"); | |
| 178 | expect(text).toContain("no limit"); | |
| 179 | }); | |
| 180 | }); |
The API has the read and the write, but
src/http/mcp.tshas no matching tool.limitsandcapacityboth have one with dryRun and an admin-scope check. Add aquotastool the same shape and test it.