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

Every limit the owner can hit is listed on one Limits page with its value and its reason, read from settings, not constants #1582

Merged opened this

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

Merged

scout/forge:scout/1476-every-limit-the-owner-can-hit-is-listed → main 5 commits fork 2 threads · 2 unresolved

  • Merged. scout/1476-every-limit-the-owner-can-hit-is-listed was merged into main as d4da9ed.

Review

Sign in to review this change.
24 files changed +885 −46
README.md +4
@@ -702,3 +702,7 @@ Invariants are proven by faults, not by hope. No dependencies were added.
702702 `forge.json`'s `check` may declare `fast` beside `run` (the full gate; `full` names it explicitly). `bun scripts/test-shards.ts --tier fast --diff <base>..<head>` runs the tests that touch the diff (changed, named for a changed source file, or importing one within two hops), every test the times table records under 1 s, and any named by `--learned a,b`. `src/checks/tiers.ts` decides; it adds no dependency.
703703
704704 In the forge itself: a pull's check runs `fast` when the manifest has one, in a clone holding the fork point and the head as `FORGE_CHECK_BASE` and `HEAD` (`origin/main` is not a ref there), with `FORGE_CHECK_LEARNED` naming tests earlier land attempts taught the pull (`src/checks/history.ts`, `learned.ts`). Landing (`landIfGreen`) runs `full` once on the merge with its base before merging (`src/checks/landing.ts`); red comments on the pull with the tests named, is remembered per head and base, and the files ride the pull's next fast run. The pull page's checks line shows the tier words.
705
706 ## Limits
707
708 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.
docs/APPS.md +5
@@ -1081,6 +1081,11 @@ the data directory `FORGE_APP_DATA`, and the defaults become
10811081 `FORGE_SSH_PORT=0`, `FORGE_CREW_CONCURRENCY=1` and
10821082 `FORGE_APP_DOMAIN=--<its own host>`.
10831083
1084 Every limit an owner can hit (crew runs and checks at once, budget
1085 ceilings, per-account caps, sign-in rates) is listed with its value and its
1086 reason under **Settings → Limits** (`/admin/limits`, forge admin only),
1087 declared in `settings/limits.ts` and read on every use.
1088
10841089 Four of these are the forge's capacity and change while it runs: crew runs
10851090 at once, checks at once, shards per check, and the cells' egress mode. A
10861091 forge admin sets them under **Settings → Capacity**, through
scripts/gate.ts +1
@@ -90,6 +90,7 @@ export const GATE: readonly string[] = [
9090 "test/decide.test.ts",
9191 "test/ledger.test.ts",
9292 "test/capacity.test.ts",
93 "test/limits.test.ts",
9394 "test/guard-new-accounts.test.ts",
9495 "test/checks.test.ts",
9596 "test/dependencies.test.ts",
src/actions/app_members.ts +3 −5
@@ -21,9 +21,7 @@ import { canWrite, type Repo } from "../core/repos.ts";
2121 import { getUserByName, isActive, type User } from "../core/users.ts";
2222 import { err, ok, type Result } from "../kernel/result.ts";
2323 import { requireReader } from "./access.ts";
24
25 /** The most names one app may hold. */
26 export const MAX_APP_MEMBERS = 100;
24 import { limitValue } from "../settings/limits.ts";
2725
2826 export type AppMembersError =
2927 | { readonly kind: "not_found" }
@@ -84,10 +82,10 @@ export function setAppMembersThrough(
8482 const names = [
8583 ...new Set(input.members.map((n) => n.trim()).filter((n) => n !== "")),
8684 ];
87 if (names.length > MAX_APP_MEMBERS)
85 if (names.length > limitValue(app.db, "app.members_max"))
8886 return err({
8987 kind: "invalid",
90 message: `name at most ${MAX_APP_MEMBERS} people`,
88 message: `name at most ${limitValue(app.db, "app.members_max")} people`,
9189 });
9290 const people: User[] = [];
9391 for (const name of names) {
src/actions/crew.ts +5 −4
@@ -24,7 +24,6 @@ import {
2424 type AgentPatch,
2525 type CrewRole,
2626 BRIEF_MAX,
27 BUDGET_MAX_USD,
2827 MODEL_MAX,
2928 } from "../core/agents.ts";
3029 import { canWrite, type Repo } from "../core/repos.ts";
@@ -36,6 +35,7 @@ import {
3635 } from "../core/users.ts";
3736 import { err, ok, type Result } from "../kernel/result.ts";
3837 import { requireReader, requireRepoAdmin, requireUser } from "./access.ts";
38 import { limitValue } from "../settings/limits.ts";
3939
4040 export interface CreateAgentInput {
4141 username: string;
@@ -63,6 +63,7 @@ function requireForgeAdmin(
6363 }
6464
6565 function checkPatch(
66 app: App,
6667 patch: AgentPatch,
6768 ): Result<AgentPatch, "invalid_model" | "brief_too_long" | "invalid_budget"> {
6869 if (patch.model !== undefined) {
@@ -75,7 +76,7 @@ function checkPatch(
7576 patch.budgetUsd !== undefined &&
7677 (!Number.isFinite(patch.budgetUsd) ||
7778 patch.budgetUsd < 0 ||
78 patch.budgetUsd > BUDGET_MAX_USD)
79 patch.budgetUsd > limitValue(app.db, "agent.budget_max_usd"))
7980 )
8081 return err("invalid_budget");
8182 return ok(patch);
@@ -89,7 +90,7 @@ export async function createAgent(
8990 ): Promise<Result<Agent, CreateAgentError>> {
9091 const admin = requireForgeAdmin(actor);
9192 if (!admin.ok) return admin;
92 const patch = checkPatch({
93 const patch = checkPatch(app, {
9394 ...(input.model === undefined ? {} : { model: input.model }),
9495 ...(input.brief === undefined ? {} : { brief: input.brief }),
9596 ...(input.budgetUsd === undefined ? {} : { budgetUsd: input.budgetUsd }),
@@ -128,7 +129,7 @@ export function updateAgent(
128129 if (!admin.ok) return admin;
129130 const agent = getAgentByName(app.db, username);
130131 if (!agent) return err("not_found");
131 const checked = checkPatch(patch);
132 const checked = checkPatch(app, patch);
132133 if (!checked.ok) return checked;
133134 const updated = patchAgent(app.db, agent.user.id, checked.value);
134135 if (!updated) return err("not_found");
src/actions/front.ts +5 −7
@@ -46,13 +46,11 @@ import { canRead, getRepo, getReposByIds, type Repo } from "../core/repos.ts";
4646 import { listActiveRuns } from "../core/runs.ts";
4747 import { getUsersByIds, isActive, type User } from "../core/users.ts";
4848 import { err, ok, type Result } from "../kernel/result.ts";
49 import { limitValue } from "../settings/limits.ts";
4950
5051 /** How many public apps are read, and how many the shelf shows. */
5152 const READ = 200;
5253 export const SHELF = 12;
53 /** The most a site admin may pin, and hide. */
54 export const MAX_PINNED = 12;
55 export const MAX_HIDDEN = 200;
5654 /** How long a ranking is kept before it is worked out again. */
5755 const SCORES_MS = 60_000;
5856
@@ -355,10 +353,10 @@ export function setFront(
355353 use: weights["use"] as number,
356354 fresh: weights["fresh"] as number,
357355 };
358 if (input.pinned.length > MAX_PINNED)
359 return err({ kind: "invalid", message: `pin at most ${MAX_PINNED}` });
360 if (input.hidden.length > MAX_HIDDEN)
361 return err({ kind: "invalid", message: `hide at most ${MAX_HIDDEN}` });
356 if (input.pinned.length > limitValue(app.db, "front.pinned_max"))
357 return err({ kind: "invalid", message: `pin at most ${limitValue(app.db, "front.pinned_max")}` });
358 if (input.hidden.length > limitValue(app.db, "front.hidden_max"))
359 return err({ kind: "invalid", message: `hide at most ${limitValue(app.db, "front.hidden_max")}` });
362360 const resolve = (
363361 names: readonly string[],
364362 ): Result<Repo[], SetFrontError> => {
src/actions/limits.ts added +76
@@ -0,0 +1,76 @@
1 /**
2 * The forge's limits (`settings/limits.ts`), for a forge admin: listed with
3 * their value and reason, and changed. Never by an agent, whatever it was
4 * granted — a crew run that could raise its own ceilings would be the crew
5 * deciding how much it may spend. Everyone else is told `not_found`.
6 */
7
8 import type { App } from "../app.ts";
9 import type { User } from "../core/users.ts";
10 import { err, ok, type Result } from "../kernel/result.ts";
11 import {
12 LIMITS,
13 LIMIT_KEYS,
14 isLimitKey,
15 limitChanges,
16 limitDefault,
17 limitIsSet,
18 limitValue,
19 setLimit,
20 type LimitChange,
21 type LimitDef,
22 type LimitKey,
23 } from "../settings/limits.ts";
24
25 export interface LimitRow extends LimitDef {
26 readonly key: LimitKey;
27 readonly value: number;
28 readonly isSet: boolean;
29 readonly defaultValue: number;
30 }
31
32 function admin(actor: User | undefined): Result<User, "not_found"> {
33 return actor?.isAdmin === true && actor.kind !== "agent"
34 ? ok(actor)
35 : err("not_found");
36 }
37
38 export function listLimits(
39 app: App,
40 actor: User | undefined,
41 ): Result<{ limits: LimitRow[]; changes: LimitChange[] }, "not_found"> {
42 const who = admin(actor);
43 if (!who.ok) return who;
44 return ok({
45 limits: LIMIT_KEYS.map((key) => ({
46 ...LIMITS[key],
47 key,
48 value: limitValue(app.db, key),
49 isSet: limitIsSet(app.db, key),
50 defaultValue: limitDefault(app.db, key),
51 })),
52 changes: limitChanges(app.db),
53 });
54 }
55
56 export type ChangeLimitError =
57 | { readonly kind: "not_found" }
58 | { readonly kind: "no_such_limit" }
59 | { readonly kind: "out_of_bounds"; readonly message: string };
60
61 /** Set `key` to `value`; `null` goes back to the default. */
62 export function changeLimit(
63 app: App,
64 actor: User | undefined,
65 key: string,
66 value: string | null,
67 ): Result<{ key: LimitKey; from: number; to: number }, ChangeLimitError> {
68 const who = admin(actor);
69 if (!who.ok) return err({ kind: "not_found" });
70 if (!isLimitKey(key)) return err({ kind: "no_such_limit" });
71 const trimmed = value?.trim() ?? null;
72 const n = trimmed === null ? null : /^\d{1,12}$/.test(trimmed) ? Number(trimmed) : Number.NaN;
73 const set = setLimit(app.db, key, n, who.value.id);
74 if (!set.ok) return err(set.error);
75 return ok({ key, ...set.value });
76 }
src/core/agents.ts +2 −2
@@ -8,6 +8,7 @@
88 */
99
1010 import type { Database } from "bun:sqlite";
11 import { limitValue } from "../settings/limits.ts";
1112 import { getUserById, type User } from "./users.ts";
1213
1314 export interface Agent {
@@ -33,7 +34,6 @@ interface AgentRow {
3334
3435 export const MODEL_MAX = 80;
3536 export const BRIEF_MAX = 4_000;
36 export const BUDGET_MAX_USD = 100;
3737
3838 function toAgent(db: Database, row: AgentRow): Agent | undefined {
3939 const user = getUserById(db, row.user_id);
@@ -128,7 +128,7 @@ export function updateAgent(
128128 sets.push(`brief = ?${params.length}`);
129129 }
130130 if (patch.budgetUsd !== undefined) {
131 params.push(Math.min(Math.max(patch.budgetUsd, 0), BUDGET_MAX_USD));
131 params.push(Math.min(Math.max(patch.budgetUsd, 0), limitValue(db, "agent.budget_max_usd")));
132132 sets.push(`budget_usd = ?${params.length}`);
133133 }
134134 if (sets.length > 0) {
src/core/ratelimit.ts +6 −1
@@ -96,12 +96,17 @@ export class RateLimiter {
9696 private readonly windowMs: number;
9797
9898 constructor(
99 private readonly limits: RateLimits = limitsFromEnv(),
99 private limits: RateLimits = limitsFromEnv(),
100100 private readonly now: () => number = Date.now,
101101 ) {
102102 this.windowMs = limits.windowMs ?? WINDOW_MS;
103103 }
104104
105 /** Take new numbers, honoured by the next `take`; counted hits are kept. */
106 setLimits(limits: RateLimits): void {
107 this.limits = limits;
108 }
109
105110 get enabled(): boolean {
106111 return this.limits.enabled;
107112 }
src/core/shares.ts +2 −2
@@ -7,8 +7,8 @@
77
88 import type { Database } from "bun:sqlite";
99 import { id } from "../kernel/id.ts";
10 import { limitValue } from "../settings/limits.ts";
1011
11 export const DAILY_BUDGET = 5;
1212 export const QUEUE_TTL_MS = 48 * 3600 * 1000;
1313 const DAY_MS = 24 * 3600 * 1000;
1414 const WEIGHT_WINDOW_MS = 90 * DAY_MS;
@@ -189,7 +189,7 @@ export function drainQueue(
189189 )
190190 .all(recipientId);
191191 if (waiting.length === 0) return 0;
192 let room = DAILY_BUDGET - deliveredToday(db, recipientId, now);
192 let room = limitValue(db, "share.daily_max") - deliveredToday(db, recipientId, now);
193193 const weights = new Map<string, number>();
194194 const w = (s: string): number => {
195195 if (!weights.has(s)) weights.set(s, senderWeight(db, s, now));
src/core/subscriptions.ts +3 −3
@@ -28,6 +28,7 @@ import type { Database } from "bun:sqlite";
2828 import { id } from "../kernel/id.ts";
2929 import { err, ok, type Result } from "../kernel/result.ts";
3030 import { open, seal } from "../kernel/secret.ts";
31 import { limitValue } from "../settings/limits.ts";
3132
3233 export type SubscriptionKind = "oauth" | "api_key";
3334
@@ -95,7 +96,6 @@ const COMMITTED = `(s.spent_usd + (SELECT COALESCE(SUM(
9596 MAX(0, COALESCE(r.budget_reserved_usd, 0) - r.cost_usd)), 0)
9697 FROM run r WHERE r.subscription_id = s.id
9798 AND r.state IN ('queued', 'running') AND r.id <> ?4))`;
98 export const BUDGET_MAX_USD = 100_000;
9999
100100 /** An account's whole-life budget and what its runs have spent, in dollars. */
101101 export interface Budget {
@@ -124,7 +124,7 @@ export function budgetsOf(db: Database): Map<string, Budget> {
124124 * whoever made this forge is never changed from inside it.
125125 */
126126 export function setBudget(db: Database, subscriptionId: string, usd: number | null): boolean {
127 if (usd !== null && !(Number.isFinite(usd) && usd > 0 && usd <= BUDGET_MAX_USD)) return false;
127 if (usd !== null && !(Number.isFinite(usd) && usd > 0 && usd <= limitValue(db, "subscription.budget_max_usd"))) return false;
128128 const value = usd === null ? null : Math.round(usd * 100) / 100;
129129 // A locked budget may only come down: never up, never cleared.
130130 return (
@@ -143,7 +143,7 @@ export function setBudget(db: Database, subscriptionId: string, usd: number | nu
143143 * a child's starter key, so the spawner's money ends where they said.
144144 */
145145 export function lockBudget(db: Database, subscriptionId: string, usd: number): boolean {
146 if (!(Number.isFinite(usd) && usd > 0 && usd <= BUDGET_MAX_USD)) return false;
146 if (!(Number.isFinite(usd) && usd > 0 && usd <= limitValue(db, "subscription.budget_max_usd"))) return false;
147147 return (
148148 db
149149 .query(
src/feed/share.ts +2 −2
@@ -22,7 +22,6 @@ import { mutedRepos, recordInteraction } from "../core/interactions.ts";
2222 import { isOrgMember } from "../core/orgs.ts";
2323 import { canRead, getRepoById } from "../core/repos.ts";
2424 import {
25 DAILY_BUDGET,
2625 addShare,
2726 deliveredToday,
2827 drainQueue,
@@ -34,6 +33,7 @@ import {
3433 type User,
3534 } from "../core/users.ts";
3635 import { candidatesFor, type Candidate } from "./items.ts";
36 import { limitValue } from "../settings/limits.ts";
3737
3838 export const RECIPIENTS_MAX = 10;
3939
@@ -154,7 +154,7 @@ export async function share(
154154 continue;
155155 }
156156 drainQueue(db, w.id, now);
157 const room = deliveredToday(db, w.id, now) < DAILY_BUDGET;
157 const room = deliveredToday(db, w.id, now) < limitValue(db, "share.daily_max");
158158 addShare(db, {
159159 itemId: item.id,
160160 senderId: sender.id,
src/http/api.ts +31
@@ -11,6 +11,7 @@
1111 import { askCheck } from "../checks/again.ts";
1212 import { mayView, mayViewId, sealedScope } from "../core/sealed.ts";
1313 import { changeCapacity, listCapacity } from "../actions/capacity.ts";
14 import { changeLimit, listLimits } from "../actions/limits.ts";
1415 import {
1516 accountSignIns,
1617 endAccountSession,
@@ -1270,6 +1271,36 @@ export function apiRoutes(router: Router<Handler>): void {
12701271 );
12711272 });
12721273
1274 // The forge's limits (`settings/limits.ts`): the same door as capacity.
1275 router.add("GET", "/api/v1/admin/limits", (ctx) => {
1276 const denied = accountAdmin(ctx, "ro");
1277 if (denied) return denied;
1278 const listed = listLimits(ctx.app, ctx.auth.user);
1279 return listed.ok ? json(listed.value) : notFound();
1280 });
1281
1282 router.add("PUT", "/api/v1/admin/limits/:key", async (ctx) => {
1283 const denied = accountAdmin(ctx, "admin");
1284 if (denied) return denied;
1285 const input = await body(ctx.request);
1286 const value =
1287 input.value === null
1288 ? null
1289 : typeof input.value === "number" && Number.isFinite(input.value)
1290 ? String(input.value)
1291 : str(input.value);
1292 const changed = changeLimit(ctx.app, ctx.auth.user, ctx.params.key!, value);
1293 if (changed.ok) return json(changed.value);
1294 if (changed.error.kind === "not_found") return notFound();
1295 return apiError(
1296 changed.error.kind,
1297 422,
1298 changed.error.kind === "no_such_limit"
1299 ? "no such limit"
1300 : changed.error.message,
1301 );
1302 });
1303
12731304 router.add("GET", "/api/v1/admin/accounts", (ctx) => {
12741305 const denied = accountAdmin(ctx, "ro");
12751306 if (denied) return denied;
src/http/mcp.ts +41 −3
@@ -19,6 +19,8 @@
1919
2020 import { mayView, mayViewId, sealedScope } from "../core/sealed.ts";
2121 import { changeCapacity, listCapacity } from "../actions/capacity.ts";
22 import { changeLimit, listLimits } from "../actions/limits.ts";
23 import { LIMIT_KEYS } from "../settings/limits.ts";
2224 import { relationOf, takeUpdates } from "../actions/forks.ts";
2325 import { watchMilestone } from "../actions/watches.ts";
2426 import {
@@ -1051,7 +1053,7 @@ const TOOLS: readonly Tool[] = [
10511053 type: "object",
10521054 properties: {
10531055 repo: REPO_ARG,
1054 members: { type: "array", items: { type: "string" }, maxItems: 100 },
1056 members: { type: "array", items: { type: "string" } },
10551057 dryRun: { type: "boolean" },
10561058 },
10571059 required: ["repo"],
@@ -1178,8 +1180,8 @@ const TOOLS: readonly Tool[] = [
11781180 inputSchema: {
11791181 type: "object",
11801182 properties: {
1181 pinned: { type: "array", items: { type: "string" }, maxItems: 12 },
1182 hidden: { type: "array", items: { type: "string" }, maxItems: 200 },
1183 pinned: { type: "array", items: { type: "string" } },
1184 hidden: { type: "array", items: { type: "string" } },
11831185 weights: {
11841186 type: "object",
11851187 description: "What counts toward an app's place, each 0 to 10: forks, ships, use, fresh. One left out keeps what is set.",
@@ -1790,6 +1792,42 @@ const TOOLS: readonly Tool[] = [
17901792 });
17911793 },
17921794 },
1795 {
1796 name: "limits",
1797 description:
1798 "Every limit the forge's owner can hit, with its value, bounds and the reason it is there (people per app, pins, hidden repositories, budget ceilings, daily shares, sign-in attempts, crew and check concurrency). Without `key`, each limit and the recent changes. With `key` and `value` (\"\" goes back to the default), the change it would make; with dryRun false, made, by a forge admin with an admin-scope token, never an agent. Honoured by the next use, never by a restart. The same as /admin/limits and /api/v1/admin/limits.",
1799 inputSchema: {
1800 type: "object",
1801 properties: {
1802 key: { type: "string", enum: [...LIMIT_KEYS] },
1803 value: { type: "string", maxLength: 16 },
1804 dryRun: { type: "boolean", description: "Plan only (the default). Send false to change it." },
1805 },
1806 },
1807 writes: true,
1808 dryByDefault: true,
1809 run(ctx, user, args) {
1810 const listed = listLimits(ctx.app, user);
1811 if (!listed.ok) return bad("not_found", "no such tool here");
1812 const key = typeof args["key"] === "string" ? args["key"] : undefined;
1813 if (key === undefined) return good(listed.value);
1814 const value = typeof args["value"] === "string" ? args["value"] : "";
1815 const now = listed.value.limits.find((l) => l.key === key);
1816 if (now === undefined) return bad("no_such_limit", `no limit ${key}`);
1817 if (args["dryRun"] !== false)

Low: the dry run reports Would set front.pinned_max from 12 to lots (or to 0, or above the max) and only refuses the value once dryRun:false is sent. The plan should give the same answer the write would. Run validLimit/limitBounds (or the same parse changeLimit does) before you build says, and return out_of_bounds from the dry run. capacity has the same gap at :1781, so this can be a follow-up for both.

1818 return good({
1819 says: `Would set ${key} from ${now.value} to ${value || `its default, ${now.defaultValue}`}. Send dryRun: false to change it.`,
1820 });
1821 if (!scopeAllows(ctx.auth.scope ?? "ro", "admin"))
1822 return bad("forbidden", "changing the forge's limits needs an admin-scope token");
1823 const changed = changeLimit(ctx.app, user, key, value === "" ? null : value);
1824 if (!changed.ok) return bad(changed.error.kind, changed.error.kind === "out_of_bounds" ? changed.error.message : "no such limit");
1825 return good({
1826 says: `${key}: ${changed.value.from} → ${changed.value.to}, from the next use on.`,
1827 ...changed.value,
1828 });
1829 },
1830 },
17931831 ];
17941832
17951833 /**
src/http/server.ts +3
@@ -8,6 +8,7 @@
88 import { TOOLBAR_NAME, toolbarResponse } from "../apps/toolbar.ts";
99 import { unsealReleased } from "../actions/sealed.ts";
1010 import { capacityPage, capacitySet } from "../web/pages/capacity.ts";
11 import { limitsPage, limitsSet } from "../web/pages/admin-limits.ts";
1112 import {
1213 closeStreamsOf,
1314 closeStreamsOfSession,
@@ -666,6 +667,8 @@ export function buildRouter(): Router<Handler> {
666667 router.add("POST", "/settings/cloud/remove", cloudRemove);
667668 router.add("GET", "/settings/capacity", capacityPage);
668669 router.add("POST", "/settings/capacity", capacitySet);
670 router.add("GET", "/admin/limits", limitsPage);
671 router.add("POST", "/admin/limits", limitsSet);
669672 router.add("GET", "/settings/backups", backupsPage);
670673 router.add("POST", "/settings/backups/now", backupsNow);
671674 router.add("POST", "/settings/backups/destination", backupsDestination);
src/settings/limits.ts added +295
@@ -0,0 +1,295 @@
1 /**
2 * Every limit a forge's owner can hit, in one place: its name, default,
3 * bounds and the one sentence that says why it is there. The code that
4 * enforces a limit asks `limitValue` on every use, so a change on
5 * `/admin/limits` governs the next request and never needs a restart.
6 *
7 * Values live in `forge_setting` (like capacity, `core/capacity.ts`); the
8 * two crew/check concurrency limits *are* capacity settings, so they are
9 * read and written there and listed here with the bounds capacity holds —
10 * one stored value, one place that says what it may be. Every change is
11 * kept in `forge_setting_change`: who, when, from what to what.
12 * Pure data; authorization is in `actions/limits.ts`.
13 */
14
15 import type { Database } from "bun:sqlite";
16 import {
17 CAPACITY,
18 bootDefault,
19 capacityValue,
20 setCapacity,
21 type CapacityKey,
22 } from "../core/capacity.ts";
23 import { err, ok, type Result } from "../kernel/result.ts";
24
25 export type LimitKey =
26 | "crew.concurrency"
27 | "checks.concurrency"
28 | "agent.budget_max_usd"
29 | "subscription.budget_max_usd"
30 | "app.members_max"
31 | "front.pinned_max"
32 | "front.hidden_max"
33 | "share.daily_max"
34 | "signin.per_account"
35 | "signin.per_forge";
36
37 export interface LimitDef {
38 readonly label: string;
39 readonly unit: string;
40 readonly default: number;
41 readonly min: number;
42 readonly max: number;
43 /** One sentence: why the limit is there. */
44 readonly reason: string;
45 }
46
47 function capacityLimit(key: CapacityKey, unit: string, reason: string): LimitDef {
48 const kind = CAPACITY[key].kind;
49 if (kind.type !== "count") throw new Error(`${key} is not a count`);
50 return {
51 label: CAPACITY[key].label,
52 unit,
53 default: Number(CAPACITY[key].fallback),
54 min: kind.min,
55 max: kind.max,
56 reason,
57 };
58 }
59
60 export const LIMITS: Record<LimitKey, LimitDef> = {
61 "crew.concurrency": capacityLimit(

Note: crew and check concurrency can now be edited on both Settings → Capacity and Settings → Limits. They store a single value, so nothing drifts, but the direction says a setting is one line where it belongs. Later, either list them on Limits read-only with a link to Capacity, or fold Capacity into Limits. Also, every other settings page lives under /settings/…, but this one is at /admin/limits.

62 "crew.concurrency",
63 "runs",
64 "The crew shares one machine; more runs at once slow every run and every page.",
65 ),
66 "checks.concurrency": capacityLimit(
67 "checks.concurrency",
68 "checks",
69 "A check runs a whole test suite; more at once starve the web process of CPU.",
70 ),
71 "agent.budget_max_usd": {
72 label: "Crew member budget ceiling",
73 unit: "dollars",
74 default: 100,
75 min: 1,
76 max: 1_000_000,
77 reason: "A crew member's per-run budget is spent unattended, so what one run may cost has a top.",
78 },
79 "subscription.budget_max_usd": {
80 label: "Pool account budget ceiling",
81 unit: "dollars",
82 default: 100_000,
83 min: 1,
84 max: 100_000_000,
85 reason: "A sealed account's whole-life budget is a promise about real money, so a typo cannot make it unbounded.",
86 },
87 "app.members_max": {
88 label: "People named on one app",
89 unit: "people",
90 default: 100,
91 min: 1,
92 max: 10_000,
93 reason: "The member list is read on every request to the app, so it stays short enough to scan.",
94 },
95 "front.pinned_max": {
96 label: "Pinned repositories on the front page",
97 unit: "repositories",
98 default: 12,
99 min: 1,
100 max: 100,
101 reason: "Pins sit above the fold; past a dozen they stop being picks.",
102 },
103 "front.hidden_max": {
104 label: "Hidden repositories on the front page",
105 unit: "repositories",
106 default: 200,
107 min: 1,
108 max: 10_000,
109 reason: "The hidden list is checked while the front page is ranked, so it is kept bounded.",
110 },
111 "share.daily_max": {
112 label: "Shares delivered to one person a day",
113 unit: "shares",
114 default: 5,
115 min: 1,
116 max: 1_000,
117 reason: "A person's feed is theirs: shares past the day's allowance wait instead of crowding it out.",
118 },
119 "signin.per_account": {
120 label: "Sign-in attempts per account a minute",
121 unit: "attempts",
122 default: 20,
123 min: 1,
124 max: 1_000,
125 reason: "Every attempt costs a password hash, and a guesser gets only so many tries at one account.",
126 },
127 "signin.per_forge": {
128 label: "Sign-in attempts per forge a minute",
129 unit: "attempts",
130 default: 200,
131 min: 1,
132 max: 100_000,
133 reason: "Spread across many accounts, guessing still burns CPU on hashes, so the forge as a whole has a ceiling.",
134 },
135 };
136
137 export const LIMIT_KEYS = Object.keys(LIMITS) as LimitKey[];
138
139 export function isLimitKey(key: string): key is LimitKey {
140 return Object.hasOwn(LIMITS, key);
141 }
142
143 /** Which limits are capacity settings and so stored there. */
144 function capacityKey(key: LimitKey): CapacityKey | undefined {
145 return key === "crew.concurrency" || key === "checks.concurrency"
146 ? key
147 : undefined;
148 }
149
150 function storeKey(key: LimitKey): string {
151 return `limit.${key}`;
152 }
153
154 /** Whether `n` is a whole number inside `key`'s bounds. */
155 export function validLimit(key: LimitKey, n: number): boolean {
156 const def = LIMITS[key];
157 return Number.isInteger(n) && n >= def.min && n <= def.max;
158 }
159
160 /** The sentence that names a limit's bounds, for a refusal. */
161 export function limitBounds(key: LimitKey): string {
162 const def = LIMITS[key];
163 return `${def.label} is a whole number from ${def.min} to ${def.max}`;
164 }
165
166 function stored(db: Database, key: LimitKey): number | undefined {
167 const row = db
168 .query<{ value: string }, [string]>(
169 "SELECT value FROM forge_setting WHERE key = ?1",
170 )
171 .get(storeKey(key));
172 if (row === null || !/^\d+$/.test(row.value)) return undefined;
173 const n = Number(row.value);
174 return validLimit(key, n) ? n : undefined;
175 }
176
177 /** The value in force when nobody has set one. */
178 export function limitDefault(db: Database, key: LimitKey): number {
179 const cap = capacityKey(key);
180 if (cap !== undefined) {
181 const n = Number(bootDefault(db, cap));
182 return validLimit(key, n) ? n : LIMITS[key].default;
183 }
184 return LIMITS[key].default;
185 }
186
187 /** What `key` is now. Read on every use, never once at boot. */
188 export function limitValue(db: Database, key: LimitKey): number {
189 const cap = capacityKey(key);
190 if (cap !== undefined) {
191 const n = Number(capacityValue(db, cap));
192 return validLimit(key, n) ? n : limitDefault(db, key);
193 }
194 return stored(db, key) ?? LIMITS[key].default;
195 }
196
197 /** Whether an admin has set `key` (rather than it being the default). */
198 export function limitIsSet(db: Database, key: LimitKey): boolean {
199 const cap = capacityKey(key);
200 if (cap !== undefined)
201 return (
202 db
203 .query("SELECT 1 FROM forge_setting WHERE key = ?1")
204 .get(cap) !== null
205 );
206 return stored(db, key) !== undefined;
207 }
208
209 export type LimitError = {
210 readonly kind: "out_of_bounds";
211 readonly message: string;
212 };
213
214 /**
215 * Set `key` to `value` (`null` goes back to the default) and keep the
216 * change. A value outside the bounds is refused with the bounds named.
217 */
218 export function setLimit(
219 db: Database,
220 key: LimitKey,
221 value: number | null,
222 actorId: string,
223 ): Result<{ from: number; to: number }, LimitError> {
224 if (value !== null && !validLimit(key, value))
225 return err({ kind: "out_of_bounds", message: limitBounds(key) });
226 const from = limitValue(db, key);
227 const cap = capacityKey(key);
228 if (cap !== undefined) {
229 setCapacity(db, cap, value === null ? null : String(value), actorId);
230 return ok({ from, to: limitValue(db, key) });
231 }
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 })();
247 return ok({ from, to: limitValue(db, key) });
248 }
249
250 export interface LimitChange {
251 readonly key: LimitKey;
252 readonly from: string;
253 readonly to: string;
254 readonly actorName: string;
255 readonly at: string;
256 }
257
258 /** The most recent changes to any listed limit, newest first. */
259 export function limitChanges(db: Database, limit = 20): LimitChange[] {
260 const marks = LIMIT_KEYS.map((_, i) => `?${i + 1}`).join(", ");
261 const rows = db
262 .query<
263 {
264 key: string;
265 from_value: string;
266 to_value: string;
267 username: string | null;
268 at: string;
269 },
270 string[]
271 >(
272 `SELECT c.key, c.from_value, c.to_value, u.username, c.at
273 FROM forge_setting_change c LEFT JOIN user u ON u.id = c.actor_id
274 WHERE c.key IN (${marks})
275 ORDER BY c.at DESC, c.rowid DESC
276 LIMIT ${Math.trunc(limit)}`,
277 )
278 .all(...LIMIT_KEYS.map(storedChangeKey));
279 return rows.map((r) => ({
280 key: fromChangeKey(r.key),
281 from: r.from_value,
282 to: r.to_value,
283 actorName: r.username ?? "",
284 at: r.at,
285 }));
286 }
287
288 /** The key a change is kept under: capacity's own, or `limit.` + the key. */
289 function storedChangeKey(key: LimitKey): string {
290 return capacityKey(key) ?? storeKey(key);
291 }
292
293 function fromChangeKey(key: string): LimitKey {
294 return (key.startsWith("limit.") ? key.slice(6) : key) as LimitKey;
295 }
src/web/pages/admin-limits.ts added +149
@@ -0,0 +1,149 @@
1 /**
2 * Limits, for a forge admin: every limit the owner can hit, with its value,
3 * its default and the reason it is there (`settings/limits.ts`). A change
4 * governs the next request that meets the limit; each one is listed below:
5 * who, when, from what to what. Anyone else gets the not-found page.
6 */
7
8 import { changeLimit, listLimits } from "../../actions/limits.ts";
9 import type { Ctx } from "../../http/context.ts";
10 import { notFoundPage, settingsLayout } from "../chrome.ts";
11 import { field, readForm } from "../form.ts";
12 import { html, when } from "../html.ts";
13 import { flashTo, page } from "../layout.ts";
14 import { btn, card, icon, row, time } from "../ui.ts";
15 import { accountSections } from "./origins.ts";
16
17 // GET /admin/limits
18 export function limitsPage(ctx: Ctx): Response {
19 const listed = listLimits(ctx.app, ctx.auth.user);
20 if (!listed.ok) return notFoundPage(ctx);
21 const { limits, changes } = listed.value;
22 const rows = limits.map((l) =>
23 row({
24 lead: icon("activity"),
25 title: html`${l.label} <span class="muted small mono">${l.key}</span>`,
26 meta: html`${l.reason} <br />${l.isSet
27 ? html`Set here; the default is
28 <span class="mono">${String(l.defaultValue)}</span>.`
29 : html`The default.`}
30 Whole number, ${l.unit}, from
31 <span class="mono">${String(l.min)}</span> to
32 <span class="mono">${String(l.max)}</span>.`,
33 trail: html`<form class="row-actions" method="post" action="/admin/limits">
34 <input type="hidden" name="key" value="${l.key}" />
35 <input
36 class="input mono"
37 type="number"
38 name="value"
39 min="${String(l.min)}"
40 max="${String(l.max)}"
41 step="1"
42 value="${String(l.value)}"
43 aria-label="${l.label}"
44 />
45 ${btn("Save", {
46 size: "sm",
47 variant: "primary",
48 label: `Save ${l.label}`,
49 })}
50 ${when(
51 l.isSet,
52 () =>
53 html`${btn("Default", {
54 size: "sm",
55 name: "reset",
56 value: "1",
57 label: `Back to the default for ${l.label}`,
58 })}`,
59 )}
60 </form>`,
61 }),
62 );
63 const history = changes.map((c) =>
64 row({
65 lead: icon("history"),
66 title: html`<span class="mono">${c.key}</span>
67 <span class="mono">${c.from}</span> →
68 <span class="mono">${c.to}</span>`,
69 meta: html`${c.actorName === "" ? "someone" : c.actorName} · ${time(c.at)}`,
70 }),
71 );
72 const body = html`${card(
73 html`<div class="card-body">
74 <p class="muted small">
75 Every limit you can hit, and why it is there. A change governs
76 the next request that meets the limit; nothing needs a restart.
77 </p>
78 </div>
79 <ul class="list">
80 ${rows}
81 </ul>`,
82 {
83 head: html`${icon("activity")}
84 <h2>Limits</h2>`,
85 pad: false,
86 },
87 )}
88 ${card(
89 history.length === 0
90 ? html`<div class="card-body">
91 <p class="muted small">No change yet.</p>
92 </div>`
93 : html`<ul class="list">
94 ${history}
95 </ul>`,
96 {
97 head: html`${icon("history")}
98 <h2>Changes</h2>`,
99 pad: false,
100 },
101 )}`;
102 return page({
103 ctx,
104 title: "Limits",
105 body: html`<div class="page-head"><h1>Limits</h1></div>
106 ${settingsLayout(
107 accountSections("limits", { admin: true }),
108 body,
109 "Account settings",
110 )}`,
111 });
112 }
113
114 // POST /admin/limits
115 export async function limitsSet(ctx: Ctx): Promise<Response> {
116 // Checked before the body is read: a non-admin learns nothing of the door.
117 if (ctx.auth.user?.isAdmin !== true || ctx.auth.user.kind === "agent")
118 return notFoundPage(ctx);
119 const form = await readForm(ctx, "admin");
120 if (form instanceof Response) return form;
121 const key = field(form, "key");
122 const reset = field(form, "reset") === "1";
123 const changed = changeLimit(
124 ctx.app,
125 ctx.auth.user,
126 key,
127 reset ? null : field(form, "value"),
128 );
129 if (!changed.ok && changed.error.kind === "not_found")
130 return notFoundPage(ctx);
131 return flashTo(
132 ctx,
133 "/admin/limits",
134 changed.ok
135 ? {
136 message:
137 changed.value.from === changed.value.to
138 ? `${key} is already ${changed.value.to}.`
139 : `${key}: ${changed.value.from} → ${changed.value.to}.`,
140 }
141 : {
142 tone: "danger",
143 message:
144 changed.error.kind === "out_of_bounds"
145 ? `${changed.error.message}.`
146 : "There is no such limit.",
147 },
148 );
149 }
src/web/pages/crew.ts +6 −6
@@ -15,7 +15,6 @@ import {
1515 } from "../../actions/crew.ts";
1616 import {
1717 BRIEF_MAX,
18 BUDGET_MAX_USD,
1918 MODEL_MAX,
2019 type Agent,
2120 } from "../../core/agents.ts";
@@ -42,6 +41,7 @@ import {
4241 } from "../ui.ts";
4342 import { accountSections } from "./origins.ts";
4443 import { markOf } from "../../core/marks.ts";
44 import { limitValue } from "../../settings/limits.ts";
4545
4646 /** The admin, or the response that turns everyone else away. */
4747 function requireAdmin(ctx: Ctx): Response | undefined {
@@ -115,14 +115,14 @@ interface AgentValues {
115115 budgetUsd: string;
116116 }
117117
118 const AGENT_ERRORS: Record<string, string> = {
118 const agentErrors = (ctx: Ctx): Record<string, string> => ({
119119 invalid_username: "Usernames are letters, digits, dots, dashes.",
120120 username_taken: "That username is taken.",
121121 email_taken: "That username is taken.",
122122 invalid_model: `A model is up to ${MODEL_MAX} characters with no spaces.`,
123123 brief_too_long: `A brief is at most ${BRIEF_MAX} characters.`,
124 invalid_budget: `A budget is a number of dollars between 0 and ${BUDGET_MAX_USD}.`,
125 };
124 invalid_budget: `A budget is a number of dollars between 0 and ${limitValue(ctx.app.db, "agent.budget_max_usd")}.`,
125 });
126126
127127 function agentFields(values: AgentValues, error?: string): Html {
128128 return html`${selectField({
@@ -448,7 +448,7 @@ export async function agentCreate(ctx: Ctx): Promise<Response> {
448448 crewView(
449449 ctx,
450450 values,
451 AGENT_ERRORS[made.error] ?? "That agent could not be created.",
451 agentErrors(ctx)[made.error] ?? "That agent could not be created.",
452452 ),
453453 422,
454454 );
@@ -540,7 +540,7 @@ export async function agentUpdate(ctx: Ctx): Promise<Response> {
540540 agentView(
541541 agent,
542542 values,
543 AGENT_ERRORS[updated.error] ?? "Those changes could not be saved.",
543 agentErrors(ctx)[updated.error] ?? "Those changes could not be saved.",
544544 ),
545545 422,
546546 );
src/web/pages/origins.ts +7
@@ -80,6 +80,7 @@ export type AccountSection =
8080 | "backups"
8181 | "secrets"
8282 | "capacity"
83 | "limits"
8384 | "cloud";
8485
8586 /**
@@ -131,6 +132,12 @@ export function accountSections(
131132 icon: "activity" as const,
132133 current: current === "capacity",
133134 },
135 {
136 label: "Limits",
137 href: "/admin/limits",
138 icon: "activity" as const,
139 current: current === "limits",
140 },
134141 {
135142 label: "Backups",
136143 href: "/settings/backups",
src/web/pages/site.ts +8 −7
@@ -8,6 +8,7 @@
88 * typed still in it. A dead end that loses input is a bug, not a message.
99 */
1010
11 import { limitValue } from "../../settings/limits.ts";
1112 import { mayView } from "../../core/sealed.ts";
1213 import { signOutHere } from "../../actions/signins.ts";
1314 import { clientAddress } from "../../http/peer.ts";
@@ -463,19 +464,19 @@ export function signupPage(ctx: Ctx): Response {
463464 * limiter per forge, so a test's forge never throttles another's.
464465 */
465466 const signinLimits = new WeakMap<Ctx["app"], RateLimiter>();
466 const SIGNIN_PER_ACCOUNT = 20;
467 const SIGNIN_PER_FORGE = 200;
468467
469468 function signinAllowed(ctx: Ctx, username: string): number | undefined {
470469 let limiter = signinLimits.get(ctx.app);
471470 if (limiter === undefined) {
472 limiter = new RateLimiter({
473 perMinute: SIGNIN_PER_ACCOUNT,
474 adminPerMinute: SIGNIN_PER_FORGE,
475 enabled: true,
476 });
471 limiter = new RateLimiter({ perMinute: 0, adminPerMinute: 0, enabled: true });
477472 signinLimits.set(ctx.app, limiter);
478473 }
474 // Read on every attempt, so a change on /admin/limits governs the next one.
475 limiter.setLimits({
476 perMinute: limitValue(ctx.app.db, "signin.per_account"),
477 adminPerMinute: limitValue(ctx.app.db, "signin.per_forge"),
478 enabled: true,
479 });
479480 const whole = limiter.take("forge", "admin");
480481 if (!whole.allowed) return whole.retryAfter;
481482 const one = limiter.take(`account:${username.toLowerCase()}`, "ro");
src/web/pages/subscriptions.ts +3 −3
@@ -9,7 +9,6 @@ import { listAgents, type Agent } from "../../core/agents.ts";
99 import {
1010 addSubscription,
1111 API_BASE_MAX,
12 BUDGET_MAX_USD,
1312 budgetsOf,
1413 deleteSubscription,
1514 EVERYONE,
@@ -59,6 +58,7 @@ import { readSpend } from "../../actions/spend.ts";
5958 import { liveRegionsJs } from "../client.ts";
6059 import { spendView } from "./spend.ts";
6160 import { markOf } from "../../core/marks.ts";
61 import { limitValue } from "../../settings/limits.ts";
6262
6363 const POOL = "/settings/subscriptions";
6464
@@ -250,7 +250,7 @@ export function subscriptionsPage(ctx: Ctx): Response {
250250 name="budget"
251251 type="number"
252252 min="1"
253 max="${budget?.locked ? budget.budgetUsd : BUDGET_MAX_USD}"
253 max="${budget?.locked ? budget.budgetUsd : limitValue(ctx.app.db, "subscription.budget_max_usd")}"
254254 step="1"
255255 placeholder="none"
256256 value="${budget?.budgetUsd ?? ""}"
@@ -485,7 +485,7 @@ export async function subscriptionBudget(ctx: Ctx): Promise<Response> {
485485 POOL,
486486 saved
487487 ? { message: usd === null ? "No budget: this account spends until you stop it." : `Budget set: ${dollars(usd)} in all.` }
488 : { tone: "danger", message: `A budget is dollars from 1 to ${BUDGET_MAX_USD}, or empty for none.` },
488 : { tone: "danger", message: `A budget is dollars from 1 to ${limitValue(ctx.app.db, "subscription.budget_max_usd")}, or empty for none.` },
489489 );
490490 }
491491
test/limits.test.ts added +193
@@ -0,0 +1,193 @@
1 /**
2 * Every limit is a setting with its reason beside it (#1476): a forge
3 * admin lists and changes them on /admin/limits, a value out of bounds is
4 * refused with its bound named, each change is kept, and a changed limit
5 * changes what it governs on the very next use. Anyone else gets a 404.
6 */
7
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { createApp, type App } from "../src/app.ts";
13 import { createAgent } from "../src/actions/crew.ts";
14 import { setFront } from "../src/actions/front.ts";
15 import { capacityCount } from "../src/core/capacity.ts";
16 import { createToken } from "../src/core/tokens.ts";
17 import { createUser, type User } from "../src/core/users.ts";
18 import { serve, type Servers } from "../src/http/server.ts";
19 import {
20 LIMITS,
21 LIMIT_KEYS,
22 limitChanges,
23 limitValue,
24 } from "../src/settings/limits.ts";
25
26 describe("limits", () => {
27 let dataDir: string;
28 let app: App;
29 let servers: Servers;
30 let alice: User;
31 let bob: User;
32
33 const login = async (username: string, password = "a-good-passphrase") => {
34 const res = await fetch(`${servers.url}/signin`, {
35 method: "POST",
36 redirect: "manual",
37 headers: {
38 "content-type": "application/x-www-form-urlencoded",
39 origin: servers.url,
40 },
41 body: new URLSearchParams({ username, password }),
42 });
43 return {
44 status: res.status,
45 cookie: (res.headers.get("set-cookie") ?? "").split(";")[0] ?? "",
46 text: await res.text(),
47 };
48 };
49 const post = (cookie: string, body: Record<string, string>, origin = servers.url) =>
50 fetch(`${servers.url}/admin/limits`, {
51 method: "POST",
52 redirect: "manual",
53 headers: {
54 cookie,
55 "content-type": "application/x-www-form-urlencoded",
56 origin,
57 },
58 body: new URLSearchParams(body),
59 });
60
61 beforeAll(async () => {
62 dataDir = await mkdtemp(join(tmpdir(), "forge-limits-"));
63 app = createApp({ dataDir, port: 0 });
64 servers = await serve(app);
65 for (const [username, first] of [["alice", true], ["bob", false]] as const) {
66 const made = await createUser(app.db, {
67 trusted: true,
68 username,
69 email: `${username}@forge.local`,
70 password: "a-good-passphrase",
71 adminIfFirst: first,
72 });
73 if (!made.ok) throw new Error(made.error);
74 if (username === "alice") alice = made.value;
75 else bob = made.value;
76 }
77 });
78 afterAll(async () => {
79 await servers.stop();
80 await rm(dataDir, { recursive: true, force: true });
81 });
82
83 test("a non-admin, signed in or not, gets a 404 and changes nothing", async () => {
84 const bob = (await login("bob")).cookie;
85 for (const cookie of [bob, ""]) {
86 const got = await fetch(`${servers.url}/admin/limits`, { headers: { cookie } });
87 expect(got.status).toBe(404);
88 expect((await post(cookie, { key: "front.pinned_max", value: "50" })).status).toBe(404);
89 }
90 expect(limitValue(app.db, "front.pinned_max")).toBe(LIMITS["front.pinned_max"].default);
91 expect(limitChanges(app.db)).toEqual([]);
92 });
93
94 test("the page lists every limit with its value and its reason", async () => {
95 const { cookie } = await login("alice");
96 const res = await fetch(`${servers.url}/admin/limits`, { headers: { cookie } });
97 expect(res.status).toBe(200);
98 const text = await res.text();
99 for (const key of LIMIT_KEYS) {
100 expect(text).toContain(key);
101 expect(text).toContain(LIMITS[key].reason.replace(/'/g, "&#39;"));
102 }
103 });
104
105 test("a value out of bounds is refused with its bound named; a cross-origin post is refused", async () => {
106 const { cookie } = await login("alice");
107 const def = LIMITS["front.pinned_max"];
108 for (const value of [String(def.max + 1), "0", "-3", "1.5", "lots", ""]) {
109 const res = await post(cookie, { key: "front.pinned_max", value });
110 expect(res.status).toBe(303);
111 const flashed = await fetch(`${servers.url}${res.headers.get("location")}`, {
112 headers: { cookie: `${cookie}; ${(res.headers.get("set-cookie") ?? "").split(";")[0]}` },
113 });
114 expect(await flashed.text()).toContain(`from ${def.min} to ${def.max}`);
115 }
116 expect(limitValue(app.db, "front.pinned_max")).toBe(def.default);
117 expect((await post(cookie, { key: "front.pinned_max", value: "20" }, "https://evil.example")).status).toBe(403);
118 expect(limitValue(app.db, "front.pinned_max")).toBe(def.default);
119 expect(limitChanges(app.db)).toEqual([]);
120 });
121
122 test("changing a limit changes what it governs: pins, a crew member's budget, sign-ins, check concurrency", async () => {
123 const { cookie } = await login("alice");
124 const names = Array.from({ length: 13 }, (_, i) => `repo${i}`);
125
126 // Pins: thirteen is over twelve until the limit is raised.
127 const before = setFront(app, alice, { pinned: names, hidden: [] }, true);
128 expect(!before.ok && before.error.kind === "invalid" && before.error.message).toBe("pin at most 12");
129 expect((await post(cookie, { key: "front.pinned_max", value: "13" })).status).toBe(303);
130 const after = setFront(app, alice, { pinned: names, hidden: [] }, true);
131 // Thirteen pins now pass the limit; only the repositories not existing refuses.
132 expect(!after.ok && after.error.kind === "invalid" && after.error.message).not.toMatch(/^pin at most/);
133 expect(after.ok).toBe(false);
134
135 // A crew member's budget: 150 dollars is over the ceiling of 100 until raised.
136 const mk = (username: string, budgetUsd: number) =>
137 createAgent(app, alice, { username, budgetUsd });
138 expect(await mk("robot-a", 150)).toEqual({ ok: false, error: "invalid_budget" });
139 await post(cookie, { key: "agent.budget_max_usd", value: "200" });
140 expect((await mk("robot-b", 150)).ok).toBe(true);
141
142 // Sign-ins: with two attempts allowed a minute, the third is turned away.
143 await post(cookie, { key: "signin.per_account", value: "2" });
144 const bad = [await login("carol", "x"), await login("carol", "x"), await login("carol", "x")];
145 expect(bad[0]!.text).not.toContain("Too many attempts");
146 expect(bad[2]!.text).toContain("Too many attempts");
147 await post(cookie, { key: "signin.per_account", reset: "1" });
148 expect(limitValue(app.db, "signin.per_account")).toBe(LIMITS["signin.per_account"].default);
149
150 // Check concurrency is the capacity setting, listed here: one value.
151 await post(cookie, { key: "checks.concurrency", value: "4" });
152 expect(limitValue(app.db, "checks.concurrency")).toBe(4);
153 expect(capacityCount(app.db, "checks.concurrency", 1)).toBe(4);
154 });
155
156 test("every change is kept with who made it and when", () => {
157 const kept = limitChanges(app.db);
158 expect(kept.map((c) => [c.key, c.from, c.to, c.actorName])).toEqual([
159 ["checks.concurrency", "1", "4", "alice"],
160 ["signin.per_account", "2", "20", "alice"],
161 ["signin.per_account", "20", "2", "alice"],
162 ["agent.budget_max_usd", "100", "200", "alice"],
163 ["front.pinned_max", "12", "13", "alice"],
164 ]);
165 expect(kept.every((c) => Date.parse(c.at) > 0)).toBe(true);
166 });
167
168 test("the API lists and changes limits for an admin only, and refuses out-of-bounds values", async () => {
169 const adminToken = createToken(app.db, alice.id, "t", "admin").token;
170 const bobToken = createToken(app.db, bob.id, "t", "admin").token;
171 const api = (token: string, method: string, path: string, body?: unknown) =>
172 fetch(`${servers.url}/api/v1${path}`, {
173 method,
174 headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
175 ...(body === undefined ? {} : { body: JSON.stringify(body) }),
176 });
177 const listed = await api(adminToken, "GET", "/admin/limits");
178 expect(listed.status).toBe(200);
179 expect(((await listed.json()) as { limits: unknown[] }).limits).toHaveLength(LIMIT_KEYS.length);
180 expect((await api(bobToken, "GET", "/admin/limits")).status).toBe(404);
181 expect((await api(bobToken, "PUT", "/admin/limits/share.daily_max", { value: "9" })).status).toBe(404);
182 expect((await api(adminToken, "PUT", "/admin/limits/share.daily_max", { value: "0" })).status).toBe(422);
183 expect((await api(adminToken, "PUT", "/admin/limits/nothing.here", { value: "3" })).status).toBe(422);
184 expect(limitValue(app.db, "share.daily_max")).toBe(LIMITS["share.daily_max"].default);
185 const put = await api(adminToken, "PUT", "/admin/limits/share.daily_max", { value: "9" });
186 expect(put.status).toBe(200);
187 expect(limitValue(app.db, "share.daily_max")).toBe(9);
188 expect((await api(adminToken, "PUT", "/admin/limits/share.daily_max", { value: 11 })).status).toBe(200);
189 expect(limitValue(app.db, "share.daily_max")).toBe(11);
190 expect((await api(adminToken, "PUT", "/admin/limits/share.daily_max", { value: null })).status).toBe(200);
191 expect(limitValue(app.db, "share.daily_max")).toBe(LIMITS["share.daily_max"].default);
192 });
193 });
test/mcp.test.ts +32
@@ -18,6 +18,7 @@ import { createRun, listRuns } from "../src/core/runs.ts";
1818 import { createToken } from "../src/core/tokens.ts";
1919 import { createUser, type User } from "../src/core/users.ts";
2020 import { serve, type Servers } from "../src/http/server.ts";
21 import { limitChanges, limitValue } from "../src/settings/limits.ts";
2122
2223 describe("the forge over MCP", () => {
2324 let dataDir: string;
@@ -197,6 +198,7 @@ describe("the forge over MCP", () => {
197198 "hold",
198199 "plan",
199200 "capacity",
201 "limits",
200202 "report",
201203 ]);
202204 const byName = new Map<string, any>(
@@ -549,6 +551,36 @@ describe("the forge over MCP", () => {
549551 });
550552 expect(res.status).toBe(401);
551553 });
554 test("limits: admins only, dry by default, an admin-scope token to change", async () => {
555 const key = "front.pinned_max";
556 const admin = createToken(app.db, alice.id, "admin", "admin").token;
557 // A non-admin and an agent (even one marked admin) are told nothing.
558 expect((await tool(bobRw, "limits")).error).toContain("no such tool");
559 const made = await createAgent(app, alice, { username: "limitbot", brief: "x" });
560 if (!made.ok) throw new Error(String(made.error));
561 app.db.query("UPDATE user SET is_admin = 1 WHERE id = ?1").run(made.value.user.id);
562 const botToken = createToken(app.db, made.value.user.id, "bot", "admin").token;
563 expect((await tool(botToken, "limits")).error).toContain("no such tool");
564
565 const before = limitValue(app.db, key);
566 const listed = await tool(ro, "limits");
567 expect(listed.value.limits.map((l: any) => l.key)).toContain(key);
568
569 // The default is a dry run: nothing changes, even with an admin token.
570 const dry = await tool(admin, "limits", { key, value: "13" });
571 expect(dry.value.says).toContain("Would set");
572 expect(limitValue(app.db, key)).toBe(before);
573
574 // Writing needs the admin scope.
575 const refused = await tool(ro, "limits", { key, value: "13", dryRun: false });
576 expect(refused.error).toContain("forbidden");
577 expect(limitValue(app.db, key)).toBe(before);
578
579 const done = await tool(admin, "limits", { key, value: "13", dryRun: false });
580 expect(done.value).toMatchObject({ key, from: before, to: 13 });
581 expect(limitValue(app.db, key)).toBe(13);
582 expect(limitChanges(app.db)[0]).toMatchObject({ key });
583 });
552584
553585 test("ui:// card resources are listed, read, and tied to their tools", async () => {
554586 const list = await call(rw, "resources/list");
test/share.test.ts +3 −1
@@ -15,7 +15,8 @@ import { listComments } from "../src/core/issues.ts";
1515 import { recordInteraction } from "../src/core/interactions.ts";
1616 import type { Repo } from "../src/core/repos.ts";
1717 import { pin } from "../src/core/pins.ts";
18 import { DAILY_BUDGET, QUEUE_TTL_MS, senderWeight } from "../src/core/shares.ts";
18 import { LIMITS } from "../src/settings/limits.ts";
19 import { QUEUE_TTL_MS, senderWeight } from "../src/core/shares.ts";
1920 import { createToken } from "../src/core/tokens.ts";
2021 import { createUser, type User } from "../src/core/users.ts";
2122 import { readFeed } from "../src/feed/feed.ts";
@@ -24,6 +25,7 @@ import { parseTargets } from "../src/http/feed.ts";
2425 import { serve, type Servers } from "../src/http/server.ts";
2526
2627 const HOUR = 3600_000;
28 const DAILY_BUDGET = LIMITS["share.daily_max"].default;
2729
2830 describe("share", () => {
2931 let dataDir: string;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs