feat(server-mode): spend quota per user — no new session, prompts refused once used up

An admin sets a default spend quota on the Users page and overrides it per
user (Edit user → Spend quota: default / no limit / their own), in dollars
per local day or per sliding 5 hours. Spend is the users page's own
hourlyTokenRows + calculateCost over the sessions a user owns.

Over the quota, until it opens again:
- spawnRun refuses every start/resume/home prompt with EQUOTA (429);
- a minute sweep writes each pane's AGENTHUB_QUOTA_FILE (host: data dir,
  member: handoff dir via write-as) and hook-handler.js exits 2 on
  UserPromptSubmit from that file alone (Claude Code, Codex with trusted
  hooks) — fail-open, never asks the dashboard, never for Antigravity;
- a member's omp on the shared login loses its gateway key (clientsFor);
- panes no hook can stop (Antigravity, Cursor, Codex without trusted
  hooks, omp on its own login) are ended.

GET /api/me/quota feeds a "Quota N%" chip in the status bar.
New: server/lib/user-quota.js, user_quotas table, PUT /api/users/quota-default,
PATCH /api/users/:id {quota}, docs in SERVER-MODE.md § Spend quotas.
This commit is contained in:
2026-10-28 14:31:11 +07:00
parent 9e3d6f4ca1
commit fa27b299e7
29 changed files with 2044 additions and 23 deletions
+17
View File
@@ -2004,6 +2004,23 @@ db.exec(`
PRIMARY KEY (group_id, tool, account)
);
`);
// Spend quotas (server/lib/user-quota.js): user_id 0 is the default every
// user without a row of their own gets; a row whose limit_usd is NULL means
// "no limit". No foreign key (0 is no user) — a deleted user's row goes with
// the trigger. Additive.
db.exec(`
CREATE TABLE IF NOT EXISTS user_quotas (
user_id INTEGER PRIMARY KEY,
limit_usd REAL,
period TEXT NOT NULL DEFAULT 'day' CHECK(period IN ('day','5h')),
tz_offset INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE TRIGGER IF NOT EXISTS trg_user_quotas_user_gone AFTER DELETE ON users
BEGIN
DELETE FROM user_quotas WHERE user_id = OLD.id;
END;
`);
// A database from before the Cursor pool: its `tool` CHECK allowed only
// claude/codex. Rebuilt with every pin kept.
{