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

Merged opened this

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

Merged

scout/forge:scout/2092-per-account-quotas-exist-as-settings-wit → main 4 commits fork 5 threads · 5 unresolved

  • Merged. scout/2092-per-account-quotas-exist-as-settings-wit was merged into main as 484f5ab.

Review

Sign in to review this change.
10 files changed +511 −17
README.md +2
@@ -1097,6 +1097,8 @@ While the default branch's own last check is red, the forge holds its pulls' che
10971097
10981098 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.
10991099
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
11001102 ## Choosing among variants
11011103
11021104 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";
2222 import { mayView, mayViewId, sealedScope } from "../core/sealed.ts";
2323 import { changeCapacity, listCapacity } from "../actions/capacity.ts";
2424 import { changeLimit, listLimits } from "../actions/limits.ts";
25 import { changeQuotas, readQuotas } from "../actions/quotas.ts";
2526 import {
2627 accountSignIns,
2728 endAccountSession,
@@ -1436,6 +1437,34 @@ export function apiRoutes(router: Router<Handler>): void {
14361437 );
14371438 });
14381439
1440 // Per-account quotas (`core/quotas.ts`): null is no limit. Nothing

The API has the read and the write, but src/http/mcp.ts has no matching tool. limits and capacity both have one with dryRun and an admin-scope check. Add a quotas tool the same shape and test it.

This comment still says core/quotas.ts, but the module moved to settings/quotas.ts.

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
14391468 router.add("GET", "/api/v1/admin/accounts", (ctx) => {
14401469 const denied = accountAdmin(ctx, "ro");
14411470 if (denied) return denied;
src/http/mcp.ts +40
@@ -26,6 +26,8 @@ import { readPipeline, verdict } from "../flow/pipeline.ts";
2626 import { readQueues } from "../flow/queue.ts";
2727 import { readThroughput, stageOut, throughputSays } from "../flow/throughput.ts";
2828 import { changeLimit, listLimits } from "../actions/limits.ts";
29 import { changeQuotas, readQuotas } from "../actions/quotas.ts";
30 import { QUOTA_KEYS } from "../settings/quotas.ts";
2931 import { LIMIT_KEYS } from "../settings/limits.ts";
3032 import { relationOf, takeUpdates } from "../actions/forks.ts";
3133 import { watchMilestone } from "../actions/watches.ts";
@@ -2133,6 +2135,44 @@ const TOOLS: readonly Tool[] = [
21332135 });
21342136 },
21352137 },
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 value. {key:"apps", value:"0"} plans "Would set apps from no limit to 0", and then the real call refuses it. Run parseQuota(value) before planning so the plan only shows a change that would actually succeed. (The limits tool has the same gap.)

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 },
21362176 {
21372177 name: "boards",
21382178 description:
src/http/server.ts +2 −1
@@ -12,7 +12,7 @@ import { accessPage } from "../web/pages/access.ts";
1212 import { capacityPage, capacitySet } from "../web/pages/capacity.ts";
1313 import { flowPage } from "../web/pages/flow.ts";
1414 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";
1616 import { boardsPage } from "../web/pages/boards.ts";
1717 import { boardNew, boardPin, boardPost } from "../web/pages/boards-edit.ts";
1818 import { boardLive, boardShow, boardsList } from "../web/pages/boards-view.ts";
@@ -749,6 +749,7 @@ export function buildRouter(): Router<Handler> {
749749 router.add("POST", "/boards/:slug/:op", boardPost);
750750 router.add("GET", "/admin/limits", limitsPage);
751751 router.add("POST", "/admin/limits", limitsSet);
752 router.add("POST", "/admin/quotas", quotasSet);
752753 router.add("GET", "/settings/backups", backupsPage);
753754 router.add("POST", "/settings/backups/now", backupsNow);
754755 router.add("POST", "/settings/backups/drill", backupsDrill);
src/settings/limits.ts +39 −15
@@ -211,6 +211,37 @@ export type LimitError = {
211211 readonly message: string;
212212 };
213213
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
214245 /**
215246 * Set `key` to `value` (`null` goes back to the default) and keep the
216247 * change. A value outside the bounds is refused with the bounds named.
@@ -229,21 +260,14 @@ export function setLimit(
229260 setCapacity(db, cap, value === null ? null : String(value), actorId);
230261 return ok({ from, to: limitValue(db, key) });
231262 }
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 );
247271 return ok({ from, to: limitValue(db, key) });
248272 }
249273
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 QUOTA_KEYS is the place to add keys. Object.fromEntries(QUOTA_KEYS.map((k) => [k, getQuota(db, k)])) as Quotas would keep #918/#919 to a one-line change. In mcp.ts, isQuotaKey could replace the inline includes cast.

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 @@
66 */
77
88 import { changeLimit, listLimits } from "../../actions/limits.ts";
9 import { changeQuotas, readQuotas } from "../../actions/quotas.ts";
10 import { QUOTA_KEYS } from "../../settings/quotas.ts";
911 import type { Ctx } from "../../http/context.ts";
1012 import { notFoundPage, settingsLayout } from "../chrome.ts";
1113 import { field, readForm } from "../form.ts";
@@ -69,7 +71,48 @@ export function limitsPage(ctx: Ctx): Response {
6971 meta: html`${c.actorName === "" ? "someone" : c.actorName} · ${time(c.at)}`,
7072 }),
7173 );
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 quota.* changes here (who · when · from → to).

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(
73116 html`<div class="card-body">
74117 <p class="muted small">
75118 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> {
147190 },
148191 );
149192 }
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", () => {
210210 "capacity",
211211 "flow",
212212 "limits",
213 "quotas",
213214 "boards",
214215 "board_write",
215216 "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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs