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
+343
View File
@@ -0,0 +1,343 @@
/**
* @file user-quota.test.js
* @description Spend quotas per user (server/lib/user-quota.js): the default
* and a user's own setting, spend over a day and a sliding 5 h window, the
* admin routes (password again), `/api/me/quota`, the EQUOTA refusal of a new
* pane, the sweep's flag files and the panes it ends, the omp gateway keys it
* leaves out, and the hook handler refusing a prompt from the flag alone.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const fs = require("node:fs");
const os = require("node:os");
const { spawnSync } = require("node:child_process");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "user-quota-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
process.env.CLAUDE_HOME = path.join(TMP, ".claude");
process.env.CODEX_HOME = path.join(TMP, ".codex");
process.env.DASHBOARD_LIVENESS_PROBE = "0";
process.env.DASHBOARD_QUOTA_SWEEP_MS = "0";
const { createApp, startServer } = require("../index");
const { db } = require("../db");
const auth = require("../lib/auth");
const quota = require("../lib/user-quota");
const runs = require("../lib/pty-run");
let server;
let BASE;
const BOSS_PASS = "admin-pass-1";
const ids = {};
let laneId;
async function call(method, p, { cookie, body } = {}) {
const res = await fetch(BASE + p, {
method,
headers: { "Content-Type": "application/json", ...(cookie ? { cookie } : {}) },
body: body ? JSON.stringify(body) : undefined,
});
return { status: res.status, body: await res.json().catch(() => null) };
}
async function login(username, password) {
const res = await fetch(BASE + "/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
return res.headers.get("set-cookie").split(";")[0];
}
const HOUR = 3_600_000;
const hourOf = (ms) => new Date(ms).toISOString().slice(0, 13);
/** `tokens` output tokens of Sonnet 4.5 ($15 / M) booked to `sid` in the hour of `at`. */
function spend(sid, tokens, at = Date.now()) {
db.prepare(
`INSERT INTO token_usage (session_id, model, output_tokens)
VALUES (?, 'claude-sonnet-4-5-20250929', ?)
ON CONFLICT DO UPDATE SET output_tokens = output_tokens + excluded.output_tokens`
).run(sid, tokens);
db.prepare(
`INSERT INTO token_usage_hourly (session_id, hour, model, output_tokens)
VALUES (?, ?, 'claude-sonnet-4-5-20250929', ?)`
).run(sid, hourOf(at), tokens);
}
before(async () => {
process.env.AGENTHUB_MODE = "server";
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
ids.boss = (await auth.createUser("boss", BOSS_PASS, "admin")).id;
ids.mia = (await auth.createUser("mia", "member-pass-1", "developer")).id;
ids.lou = (await auth.createUser("lou", "member-pass-2", "developer")).id;
laneId = Number(
db
.prepare(
"INSERT INTO lanes (cwd, title, tool, owner_id) VALUES ('/w/mia', 'Mia', 'claude', ?)"
)
.run(ids.mia).lastInsertRowid
);
db.prepare(
"INSERT INTO sessions (id, name, status, cwd, lane_id, updated_at) VALUES ('sm', 'mia work', 'active', '/w/mia', ?, ?)"
).run(laneId, new Date().toISOString());
spend("sm", 1_000_000); // $15 now
});
after(() => {
delete process.env.AGENTHUB_MODE;
server?.close();
fs.rmSync(TMP, { recursive: true, force: true });
});
describe("settings", () => {
it("a user without a row of their own gets the default; unlimited and custom override it", () => {
assert.equal(quota.effective(ids.mia), null);
quota.setDefault({ limit_usd: 10, period: "day", tz_offset: -420 });
assert.deepEqual(quota.effective(ids.mia), {
source: "default",
limit_usd: 10,
period: "day",
tz_offset: -420,
});
quota.setUserQuota(ids.mia, { mode: "unlimited" });
assert.equal(quota.effective(ids.mia), null);
assert.deepEqual(quota.getOverride(ids.mia), { mode: "unlimited" });
quota.setUserQuota(ids.mia, { mode: "custom", limit_usd: 50, period: "5h" });
assert.equal(quota.effective(ids.mia).source, "user");
assert.equal(quota.effective(ids.mia).limit_usd, 50);
quota.setUserQuota(ids.mia, { mode: "default" });
assert.equal(quota.effective(ids.mia).source, "default");
quota.setDefault(null);
assert.equal(quota.effective(ids.mia), null);
});
it("refuses a bad limit, period or mode", () => {
for (const bad of [
{ mode: "custom", limit_usd: 0, period: "day" },
{ mode: "custom", limit_usd: 5, period: "week" },
{ mode: "custom", limit_usd: 5, period: "day", tz_offset: 9999 },
{ mode: "nope" },
]) {
assert.throws(() => quota.setUserQuota(ids.mia, bad), { code: "EBADQUOTA" });
}
});
});
describe("spend windows", () => {
it("a day starts at the zone's local midnight and resets at the next one", () => {
const now = Date.parse("2026-10-28T03:30:00Z"); // 10:30 at UTC+7
const w = quota.windowOf({ period: "day", tz_offset: -420 }, now);
assert.equal(w.since, "2026-10-27T17");
assert.equal(new Date(w.nextReset).toISOString(), "2026-10-28T17:00:00.000Z");
});
it("5 h is the current hour and the four before it; it opens as the oldest hours leave", () => {
const now = Date.parse("2026-10-28T10:20:00Z");
assert.equal(quota.windowOf({ period: "5h" }, now).since, "2026-10-28T06");
const byHour = new Map([
["2026-10-28T06", 4],
["2026-10-28T08", 3],
["2026-10-28T10", 2],
]);
// 9 spent, limit 6: 06's $4 leaving brings it to 5 < 6 → at 06 + 5 h.
assert.equal(
new Date(quota.slidingReset(byHour, 9, 6)).toISOString(),
"2026-10-28T11:00:00.000Z"
);
// limit 3: everything up to 08 must go → 08 + 5 h.
assert.equal(
new Date(quota.slidingReset(byHour, 9, 3)).toISOString(),
"2026-10-28T13:00:00.000Z"
);
});
it("counts the spend of the sessions a user owns, inside the window only", () => {
db.prepare(
"INSERT INTO sessions (id, name, status, cwd, lane_id, updated_at) VALUES ('old', 'old', 'completed', '/w/mia', ?, ?)"
).run(laneId, new Date().toISOString());
spend("old", 1_000_000, Date.now() - 8 * HOUR); // outside 5 h
quota.setUserQuota(ids.mia, { mode: "custom", limit_usd: 100, period: "5h" });
const s = quota.statusFor(ids.mia);
assert.equal(s.spend, 15);
assert.equal(s.exceeded, false);
assert.equal(s.resetsAt, null);
assert.equal(quota.statusFor(ids.lou), null);
quota.setUserQuota(ids.mia, { mode: "default" });
});
});
describe("routes", () => {
it("an admin sets the default with their password; the list shows each user's quota", async () => {
const boss = await login("boss", BOSS_PASS);
const noPass = await call("PUT", "/api/users/quota-default", {
cookie: boss,
body: { quota: { limit_usd: 10, period: "day" } },
});
assert.equal(noPass.status, 403);
const bad = await call("PUT", "/api/users/quota-default", {
cookie: boss,
body: { quota: { limit_usd: -1, period: "day" }, currentPassword: BOSS_PASS },
});
assert.equal(bad.status, 400);
assert.equal(bad.body.error.code, "EBADQUOTA");
const ok = await call("PUT", "/api/users/quota-default", {
cookie: boss,
body: { quota: { limit_usd: 10, period: "day" }, currentPassword: BOSS_PASS },
});
assert.equal(ok.status, 200, JSON.stringify(ok.body));
assert.equal(ok.body.quotaDefault.limit_usd, 10);
const list = await call("GET", "/api/users", { cookie: boss });
assert.equal(list.body.quotaDefault.limit_usd, 10);
const mia = list.body.users.find((u) => u.id === ids.mia);
assert.deepEqual(mia.quotaSetting, { mode: "default" });
assert.equal(mia.quota.spend, 15);
assert.equal(mia.quota.exceeded, true);
assert.ok(mia.quota.resetsAt);
});
it("a developer reads their own quota and may not change any", async () => {
const me = await login("mia", "member-pass-1");
const r = await call("GET", "/api/me/quota", { cookie: me });
assert.equal(r.status, 200);
assert.equal(r.body.quota.exceeded, true);
const put = await call("PUT", "/api/users/quota-default", {
cookie: me,
body: { quota: null, currentPassword: "member-pass-1" },
});
assert.equal(put.status, 403);
});
it("PATCH /api/users/:id {quota} overrides one user's, audited", async () => {
const boss = await login("boss", BOSS_PASS);
const r = await call("PATCH", `/api/users/${ids.mia}`, {
cookie: boss,
body: { quota: { mode: "custom", limit_usd: 40, period: "day" }, currentPassword: BOSS_PASS },
});
assert.equal(r.status, 200, JSON.stringify(r.body));
assert.equal(quota.statusFor(ids.mia).exceeded, false);
const audit = db
.prepare("SELECT path FROM audit_log WHERE owner_id = ? ORDER BY id DESC LIMIT 1")
.get(ids.mia);
assert.match(audit.path, /spend quota \$40\/day/);
const bad = await call("PATCH", `/api/users/${ids.mia}`, {
cookie: boss,
body: { quota: { mode: "lots" }, currentPassword: BOSS_PASS },
});
assert.equal(bad.status, 400);
quota.setUserQuota(ids.mia, { mode: "default" }); // back to the $10 default: over
});
});
describe("enforcement", () => {
it("refuses a new pane of an owner over quota with EQUOTA and a reason", () => {
assert.throws(
() => quota.assertSpawnAllowed(ids.mia),
(err) => {
assert.equal(err.code, "EQUOTA");
assert.match(err.message, /\$15\.00 of \$10\.00 per day/);
return true;
}
);
quota.assertSpawnAllowed(ids.lou); // under the default: no throw
quota.assertSpawnAllowed(null); // a workspace with no owner
});
it("gives a pane its owner's flag file in server mode only", () => {
const env = quota.flagEnv(ids.mia, null);
assert.match(env.AGENTHUB_QUOTA_FILE, /user-quota[/\\]u\d+\.json$/);
delete process.env.AGENTHUB_MODE;
try {
assert.deepEqual(quota.flagEnv(ids.mia, null), {});
quota.assertSpawnAllowed(ids.mia); // local mode never refuses
assert.equal(quota.statusAll().size, 0);
} finally {
process.env.AGENTHUB_MODE = "server";
}
});
it("sweep: writes the flag, ends the panes no hook can stop, keeps Claude's, clears on raise", () => {
const realList = runs.listRuns;
const realKill = runs.killRun;
const killed = [];
runs.listRuns = () => [
{ id: `agenthub-lane-${laneId}`, laneId, status: "running", tool: "claude" },
{ id: `agenthub-lane-${laneId}-1`, laneId, status: "running", tool: "cursor" },
];
runs.killRun = (id) => killed.push(id) > 0;
try {
quota.__reset();
const r = quota.sweep();
assert.deepEqual(r.blocked, [ids.mia]);
assert.deepEqual(killed, [`agenthub-lane-${laneId}-1`]);
const file = quota.flagEnv(ids.mia, null).AGENTHUB_QUOTA_FILE;
const flag = JSON.parse(fs.readFileSync(file, "utf8"));
assert.equal(flag.blocked, true);
assert.match(flag.message, /quota is used up/);
assert.ok(Date.parse(flag.until) > Date.now());
// Lou never went over: no file is written for them.
assert.equal(fs.existsSync(quota.flagEnv(ids.lou, null).AGENTHUB_QUOTA_FILE), false);
assert.equal(quota.isRunBlocked(`agenthub-lane-${laneId}-3`), true);
quota.setUserQuota(ids.mia, { mode: "unlimited" });
killed.length = 0;
quota.sweep();
assert.deepEqual(killed, []);
assert.equal(JSON.parse(fs.readFileSync(file, "utf8")).blocked, false);
assert.equal(quota.isRunBlocked(`agenthub-lane-${laneId}`), false);
} finally {
runs.listRuns = realList;
runs.killRun = realKill;
quota.setUserQuota(ids.mia, { mode: "default" });
quota.__reset();
}
});
it("the omp gateway loses a blocked owner's key, never the queue's", () => {
const { clientsFor } = require("../lib/omp-share");
const t = { standby: ["s1"], used: { a: { hash: "ha" }, b: { hash: "hb" } } };
const out = clientsFor(t, (id) => id === "a");
assert.equal(out.a, undefined);
assert.deepEqual(out.b, { tokenSha256: "hb" });
assert.equal(Object.keys(out).filter((k) => k.startsWith("standby-")).length, 1);
});
});
describe("hook handler", () => {
const HANDLER = path.resolve(__dirname, "..", "..", "scripts", "hook-handler.js");
const flag = path.join(TMP, "flag.json");
const run = (args, file) =>
spawnSync(process.execPath, [HANDLER, ...args], {
input: "{}",
env: { ...process.env, AGENTHUB_QUOTA_FILE: file, CLAUDE_DASHBOARD_PORT: "1" },
encoding: "utf8",
});
it("refuses a Claude Code or Codex prompt while the flag says blocked", () => {
fs.writeFileSync(
flag,
JSON.stringify({ blocked: true, until: "2999-01-01T00:00:00Z", message: "quota used up" })
);
for (const args of [["UserPromptSubmit"], ["UserPromptSubmit", "codex"]]) {
const r = run(args, flag);
assert.equal(r.status, 2);
assert.match(r.stderr, /quota used up/);
}
});
it("lets everything else through: other events, Antigravity, an expired or missing flag", () => {
assert.equal(run(["PreToolUse"], flag).status, 0);
assert.equal(run(["UserPromptSubmit", "antigravity"], flag).status, 0);
assert.equal(run(["UserPromptSubmit"], path.join(TMP, "none.json")).status, 0);
fs.writeFileSync(flag, JSON.stringify({ blocked: true, until: "2000-01-01T00:00:00Z" }));
assert.equal(run(["UserPromptSubmit"], flag).status, 0);
fs.writeFileSync(flag, "{not json");
assert.equal(run(["UserPromptSubmit"], flag).status, 0);
});
});
+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.
{
+20
View File
@@ -872,6 +872,26 @@ function startBackgroundServices() {
}, POOL_REFRESH_MS);
if (poolTimer.unref) poolTimer.unref();
}
// Server mode's spend quotas (user-quota.js): who is over theirs now, the
// flag files their hooks read, the panes no hook can stop. Off with
// DASHBOARD_QUOTA_SWEEP_MS=0; a no-op in local mode.
const QUOTA_SWEEP_MS = Number(process.env.DASHBOARD_QUOTA_SWEEP_MS ?? 60_000);
if (
Number.isFinite(QUOTA_SWEEP_MS) &&
QUOTA_SWEEP_MS > 0 &&
require("./lib/auth").isServerMode()
) {
const quotaSweep = () => {
try {
require("./lib/user-quota").sweep();
} catch (err) {
console.warn("user quota sweep failed:", err.message);
}
};
const quotaTimer = setInterval(quotaSweep, QUOTA_SWEEP_MS);
if (quotaTimer.unref) quotaTimer.unref();
setImmediate(quotaSweep);
}
// Another dashboard on the same database (hooks reach only one of them)
// moving a lane: re-broadcast it here. One pragma per beat when nothing
// changed — and only while such a dashboard runs at all: alone on its data
+17 -3
View File
@@ -108,16 +108,29 @@ function refill(t) {
while (t.standby.length < STANDBY) t.standby.push(newToken());
}
/** The gateway's view: every queued key and every key a run holds — hashes only. */
function clientsFor(t) {
/**
* The gateway's view: every queued key and every key a run holds — hashes
* only. A run whose workspace owner is over their spend quota
* (user-quota.js) is left out until it opens again: its omp can no longer
* reach a model, while its pane and key stay.
*/
function clientsFor(t, isBlocked = quotaBlocked) {
const out = {};
for (const s of t.standby) out[`standby-${sha(s).slice(0, 16)}`] = { tokenSha256: sha(s) };
for (const [id, u] of Object.entries(t.used)) {
if (u && typeof u.hash === "string") out[id] = { tokenSha256: u.hash };
if (u && typeof u.hash === "string" && !isBlocked(id)) out[id] = { tokenSha256: u.hash };
}
return out;
}
function quotaBlocked(runId) {
try {
return require("./user-quota").isRunBlocked(runId);
} catch {
return false;
}
}
let lastClients = null;
function save(t) {
@@ -519,6 +532,7 @@ module.exports = {
takeToken,
pruneTokens,
clientsFor,
clientsFor,
readTokens,
PROVIDER,
TOKEN_ENV,
+5
View File
@@ -471,6 +471,8 @@ function spawnRun(args) {
// their .mcp.json) or a member in someone else's.
if (lane) require("./run-as").assertOwnerFits(lane.cwd, laneOwner); // EOWNERFOLDER
assertCwdAccount(laneId, cwd); // ECWDACCOUNT
// Server mode: an owner over their spend quota starts nothing new (user-quota.js).
require("./user-quota").assertSpawnAllowed(laneOwner); // EQUOTA
const proxyProfile = resolveProxy(tool, proxy, laneOwner); // EBADPROXY, EUNSUPPORTED
// A Claude session on the vendor's own API runs on the least-loaded pool
// account, if the admin added any (claude-pool.js) — or the one the
@@ -684,6 +686,9 @@ function spawnRun(args) {
for (const k of Object.keys(env)) delete env[k];
Object.assign(env, own);
}
// Server mode: where this pane's hooks look for its owner's quota flag
// (user-quota.js) — a refused prompt is decided by the hook, locally.
Object.assign(env, require("./user-quota").flagEnv(laneOwner, member));
// `after-start` tools take no prompt on their command line: paste it once
// the CLI has had `delayMs` to draw its input box. ponytail: fixed delay,
+467
View File
@@ -0,0 +1,467 @@
/**
* @file user-quota.js
* @description Spend quotas per user (server mode only; local mode has one
* user and never reads any of this). An admin sets a DEFAULT quota (row
* `user_id = 0` of `user_quotas`) and may override it per user — a limit of
* their own, or none at all (`limit_usd` NULL). A quota is `limit_usd` per
* `period`: `day` (from the local midnight of `tz_offset`, the admin's
* browser's getTimezoneOffset() when they saved it) or `5h` (a sliding
* window: the current UTC hour and the four before it — spend booked in hour
* H counts until H + 5 h). Spend is the users page's own booking
* (`hourlyTokenRows` over the sessions a user OWNS through a workspace,
* priced by `calculateCost`), never a second formula.
*
* A user over their quota cannot chat on until it resets:
* - `assertSpawnAllowed` refuses every new pane (`EQUOTA`, 429) — start,
* resume and a prompt from the workspace home all pass `spawnRun`;
* - every pane carries `AGENTHUB_QUOTA_FILE` (`flagEnv`), and `sweep` writes
* that file `{blocked, until, message}` — the host's under the data dir,
* a member's (S3) in their handoff dir through `write-as` — so the hook
* handler refuses a `UserPromptSubmit` of Claude Code or Codex locally,
* without asking the dashboard (scripts/hook-handler.js; fail-open);
* - a member's omp on the shared login loses its gateway key while blocked
* (`isRunBlocked`, omp-share.js `clientsFor`);
* - a pane no hook can stop (Antigravity, Cursor, a Codex without trusted
* hooks, an omp on its own login, any other catalog tool) is ended.
* Spend lands after a turn, so a user may end one turn past the limit.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const PERIODS = ["day", "5h"];
const DEFAULT_ID = 0;
const HOUR_MS = 3_600_000;
const WINDOW_HOURS = 5;
const db = () => require("../db").db;
const fail = (code, message) => Object.assign(new Error(message), { code });
const isServerMode = () => require("./auth").isServerMode();
const hourOf = (ms) => new Date(ms).toISOString().slice(0, 13);
const hourMs = (h) => Date.parse(`${h}:00:00.000Z`);
const round = (n) => Math.round(n * 10000) / 10000;
// ── settings ──────────────────────────────────────────────────────────────
function row(userId) {
return db()
.prepare("SELECT limit_usd, period, tz_offset FROM user_quotas WHERE user_id = ?")
.get(userId);
}
const asConfig = (r, source) =>
r && r.limit_usd != null
? { source, limit_usd: r.limit_usd, period: r.period, tz_offset: r.tz_offset }
: null;
/** The default quota, or null when there is none. */
function getDefault() {
return asConfig(row(DEFAULT_ID), "default");
}
/**
* A user's own setting: `default` (no row — the default applies),
* `unlimited`, or `custom` with its limit.
*/
function getOverride(userId) {
const r = row(userId);
if (!r) return { mode: "default" };
if (r.limit_usd == null) return { mode: "unlimited" };
return { mode: "custom", limit_usd: r.limit_usd, period: r.period, tz_offset: r.tz_offset };
}
/** The quota that binds `userId` now, or null (no limit). */
function effective(userId) {
const r = row(userId);
if (r) return asConfig(r, "user");
return getDefault();
}
/** `{limit_usd, period, tz_offset}` checked; throws EBADQUOTA. */
function validate(cfg) {
const limit = Number(cfg?.limit_usd);
if (!Number.isFinite(limit) || limit <= 0 || limit > 1_000_000) {
throw fail("EBADQUOTA", "limit_usd must be a positive number of dollars");
}
if (!PERIODS.includes(cfg.period)) {
throw fail("EBADQUOTA", `period must be one of: ${PERIODS.join(", ")}`);
}
const tz = cfg.tz_offset == null ? 0 : cfg.tz_offset;
if (!Number.isInteger(tz) || Math.abs(tz) > 14 * 60) {
throw fail("EBADQUOTA", "tz_offset must be minutes within ±840");
}
return { limit_usd: limit, period: cfg.period, tz_offset: tz };
}
function upsert(userId, limit, period, tz) {
db()
.prepare(
`INSERT INTO user_quotas (user_id, limit_usd, period, tz_offset, updated_at)
VALUES (?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
ON CONFLICT(user_id) DO UPDATE SET limit_usd = excluded.limit_usd,
period = excluded.period, tz_offset = excluded.tz_offset,
updated_at = excluded.updated_at`
)
.run(userId, limit, period, tz);
}
/** Set the default quota (`null` removes it: no limit for anyone without their own). */
function setDefault(cfg) {
if (cfg == null) {
db().prepare("DELETE FROM user_quotas WHERE user_id = ?").run(DEFAULT_ID);
return null;
}
const v = validate(cfg);
upsert(DEFAULT_ID, v.limit_usd, v.period, v.tz_offset);
return getDefault();
}
/** A user's setting checked: `{mode}` or `{mode: "custom", limit_usd, period, tz_offset}`. */
function validateUserQuota(q) {
const mode = q?.mode;
if (mode === "default" || mode === "unlimited") return { mode };
if (mode === "custom") return { mode, ...validate(q) };
throw fail("EBADQUOTA", "quota.mode must be one of: default, unlimited, custom");
}
/** A user's setting: `{mode: "default" | "unlimited" | "custom", …}`. */
function setUserQuota(userId, q) {
if (!Number.isInteger(userId) || userId <= 0) throw fail("EBADQUOTA", "no such user");
const v = validateUserQuota(q);
if (v.mode === "default") {
db().prepare("DELETE FROM user_quotas WHERE user_id = ?").run(userId);
} else if (v.mode === "unlimited") {
upsert(userId, null, "day", 0);
} else {
upsert(userId, v.limit_usd, v.period, v.tz_offset);
}
return getOverride(userId);
}
/** `$5/day`, `$2.50/5h`, `no limit` — for the audit log. */
function describe(q) {
if (!q || q.mode === "unlimited") return "no limit";
if (q.mode === "default") return "the default";
return `$${q.limit_usd}/${q.period}`;
}
// ── spend ─────────────────────────────────────────────────────────────────
/**
* The window a quota counts over at `now`: `since` (a UTC hour,
* `YYYY-MM-DDTHH`, as hourly token rows are keyed) and, for a day, when the
* next one starts. Buckets are whole UTC hours, so a half-hour zone's day
* starts up to 30 min early.
*/
function windowOf(cfg, now) {
if (cfg.period === "5h") {
return { since: hourOf(now - (WINDOW_HOURS - 1) * HOUR_MS), nextReset: null };
}
const off = cfg.tz_offset || 0;
const local = new Date(now - off * 60_000);
local.setUTCHours(0, 0, 0, 0);
const start = local.getTime() + off * 60_000;
return { since: hourOf(start), nextReset: start + 24 * HOUR_MS };
}
/**
* When a 5 h quota opens again: the oldest hours leave the window first, so
* it is the end of the first hour whose leaving takes the spend under the limit.
*/
function slidingReset(byHour, spend, limit) {
let left = spend;
for (const [h, cost] of [...byHour].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
left -= cost;
if (left < limit) return hourMs(h) + WINDOW_HOURS * HOUR_MS;
}
return null;
}
/**
* Every user's quota and spend now: Map(user id → status). Only users with a
* quota are listed. One token read for all of them.
* @returns {Map<number, {limit_usd, period, tz_offset, source, spend, pct,
* exceeded: boolean, resetsAt: string|null, since: string}>}
*/
function statusAll({ now = Date.now(), userIds = null } = {}) {
const out = new Map();
if (!isServerMode()) return out;
const ids =
userIds ||
db()
.prepare("SELECT id FROM users WHERE disabled = 0")
.all()
.map((r) => r.id);
const quotas = [];
for (const id of ids) {
const cfg = effective(id);
if (cfg) quotas.push({ id, cfg, win: windowOf(cfg, now) });
}
if (!quotas.length) return out;
const { calculateCost, hourlyTokenRows } = require("../routes/pricing");
const { OWNED_SESSIONS } = require("../routes/users");
const rules = require("../db").stmts.listPricing.all();
const oldest = quotas.reduce((a, q) => (q.win.since < a ? q.win.since : a), quotas[0].win.since);
const owner = new Map();
for (const r of db().prepare(OWNED_SESSIONS).all()) {
if (!owner.has(r.session_id)) owner.set(r.session_id, r.uid);
}
// A session that spent in the window was touched in it.
const rows = hourlyTokenRows(
`WHERE COALESCE(NULLIF(s.updated_at, ''), s.started_at) >= ? AND s.id IN (SELECT session_id FROM (${OWNED_SESSIONS}))`,
[oldest]
);
const byUser = new Map();
for (const r of rows) {
const uid = owner.get(r.session_id);
if (uid == null) continue;
if (!byUser.has(uid)) byUser.set(uid, []);
byUser.get(uid).push(r);
}
for (const { id, cfg, win } of quotas) {
const mine = (byUser.get(id) || []).filter((r) => r.date >= win.since);
const spend = mine.length ? calculateCost(mine, rules).total_cost : 0;
const exceeded = spend >= cfg.limit_usd;
let resetsAt = win.nextReset;
if (cfg.period === "5h") {
const hours = new Map();
for (const r of mine) {
if (!hours.has(r.date)) hours.set(r.date, []);
hours.get(r.date).push(r);
}
const byHour = new Map(
[...hours].map(([h, list]) => [h, calculateCost(list, rules).total_cost])
);
resetsAt = exceeded ? slidingReset(byHour, spend, cfg.limit_usd) : null;
}
out.set(id, {
...cfg,
spend: round(spend),
pct: round((spend / cfg.limit_usd) * 100),
exceeded,
resetsAt: resetsAt ? new Date(resetsAt).toISOString() : null,
since: `${win.since}:00:00.000Z`,
});
}
return out;
}
/** One user's status, or null without a quota. */
function statusFor(userId, opts = {}) {
if (userId == null) return null;
return statusAll({ ...opts, userIds: [userId] }).get(userId) || null;
}
/** "HH:MM" (and the date when it is not today) in the quota's own zone. */
function localTime(iso, tzOffset, now = Date.now()) {
if (!iso) return null;
const off = (tzOffset || 0) * 60_000;
const at = new Date(Date.parse(iso) - off).toISOString();
const today = new Date(now - off).toISOString().slice(0, 10);
const utc = -(tzOffset || 0) / 60;
const zone = `UTC${utc >= 0 ? "+" : ""}${utc}`;
return `${at.slice(0, 10) === today ? "" : `${at.slice(0, 10)} `}${at.slice(11, 16)} ${zone}`;
}
/** The text a refused prompt or start shows. */
function message(s, now = Date.now()) {
const per = s.period === "5h" ? "5 hours" : "day";
const when = localTime(s.resetsAt, s.tz_offset, now);
return (
`AgentHub: your spend quota is used up — $${s.spend.toFixed(2)} of $${s.limit_usd.toFixed(2)} per ${per}.` +
(when ? ` It opens again at ${when}.` : "") +
" Ask an admin to raise it if you need more."
);
}
/** Throws EQUOTA when the workspace owner is over their quota. No-op in local mode. */
function assertSpawnAllowed(ownerId) {
if (!isServerMode() || ownerId == null) return;
const s = statusFor(ownerId);
if (s?.exceeded) throw Object.assign(fail("EQUOTA", message(s)), { quota: s });
}
// ── the flag file the hook handler reads ─────────────────────────────────
const FLAG = "quota.json";
const hostDir = () => path.join(require("./claude-home").getDataDir(), "user-quota");
const hostFlag = (userId) => path.join(hostDir(), `u${userId}.json`);
/**
* The env a pane of `ownerId` gets: where its hooks look for the flag —
* the member's handoff dir (S3: the pane runs as them) or the host's data dir.
*/
function flagEnv(ownerId, member) {
if (!isServerMode() || ownerId == null) return {};
const file = member ? path.join(require("./run-as").handoffDir(member), FLAG) : hostFlag(ownerId);
return { AGENTHUB_QUOTA_FILE: file };
}
function writeHostFlag(userId, text) {
fs.mkdirSync(hostDir(), { recursive: true, mode: 0o700 });
const file = hostFlag(userId);
const tmp = `${file}.${process.pid}.tmp`;
fs.writeFileSync(tmp, text, { mode: 0o600 });
fs.renameSync(tmp, file);
}
/** Write a user's flag everywhere their panes may read it. Best effort per place. */
function writeFlags(user, s, now) {
const body = s?.exceeded
? { blocked: true, until: s.resetsAt, message: message(s, now) }
: { blocked: false };
const text = `${JSON.stringify(body)}\n`;
try {
writeHostFlag(user.id, text);
} catch (err) {
console.warn(`user quota: flag for ${user.username} not written:`, err.message);
}
let acct = null;
try {
acct = require("./run-as").accountForUser(user.username);
} catch {
acct = null;
}
if (!acct) return;
try {
require("./run-as").handoffWrite(acct, FLAG, text);
} catch (err) {
console.warn(`user quota: ${user.username}'s flag not written:`, err.message);
}
}
// ── the sweep ────────────────────────────────────────────────────────────
/** Users over their quota now (by the last sweep). */
let blocked = new Set();
/** user id → the flag text last written, so an unchanged state writes nothing. */
const written = new Map();
function laneOwner(laneId) {
return db().prepare("SELECT owner_id FROM lanes WHERE id = ?").get(laneId)?.owner_id ?? null;
}
/** Is run `runId`'s workspace owner over quota (by the last sweep)? omp-share asks. */
function isRunBlocked(runId) {
if (!blocked.size) return false;
const laneId = require("./pty-run").laneIdFromRunId(runId);
return laneId != null && blocked.has(laneOwner(laneId));
}
/**
* Can a hook refuse this pane's next prompt? Claude Code always (the plugin's
* hooks, a member's managed-settings hooks); Codex only on the host's own
* CODEX_HOME with our UserPromptSubmit hook installed and trusted (members
* get no Codex hooks); a member's omp on the shared login through its
* gateway key. Everything else must be ended.
*/
function hookCanStop(run, member) {
if (run.tool === "claude" || run.tool === "shell") return true;
if (run.tool === "codex" && !member) {
try {
const ev = require("./codex-hooks").status().events?.UserPromptSubmit;
return !!(ev?.installed && ev?.trusted);
} catch {
return false;
}
}
if (run.tool === "omp" && member) {
try {
const share = require("./omp-share");
return share.available() && share.enabled();
} catch {
return false;
}
}
return false;
}
/** End the live panes of `userIds` that no hook can stop. Returns the run ids ended. */
function endUnstoppable(userIds) {
if (!userIds.size) return [];
const runs = require("./pty-run");
const runAs = require("./run-as");
const ended = [];
for (const run of runs.listRuns()) {
if (run.status !== "running" || !userIds.has(laneOwner(run.laneId))) continue;
if (hookCanStop(run, runAs.accountForLane(run.laneId))) continue;
try {
if (runs.killRun(run.id)) ended.push(run.id);
} catch (err) {
console.warn(`user quota: ${run.id} not ended:`, err.message);
}
}
return ended;
}
/**
* Recompute every user's state, rewrite the flags that changed, end the
* panes no hook can stop and re-list the omp gateway's keys. Called every
* minute by the server and right after an admin changes a quota.
*/
function sweep({ now = Date.now() } = {}) {
if (!isServerMode()) return { blocked: [] };
const users = db().prepare("SELECT id, username FROM users").all();
const status = statusAll({ now });
const next = new Set();
for (const u of users) {
const s = status.get(u.id) || null;
if (s?.exceeded) next.add(u.id);
const text = s?.exceeded ? `${s.resetsAt}|${s.limit_usd}` : "open";
// A user never blocked has no flag to clear: nothing is written for them.
if (written.get(u.id) === text || (!written.has(u.id) && !s?.exceeded && !hadFlag(u.id))) {
continue;
}
writeFlags(u, s, now);
written.set(u.id, text);
}
const changed = next.size !== blocked.size || [...next].some((id) => !blocked.has(id));
blocked = next;
const ended = endUnstoppable(next);
if (changed) {
try {
require("./omp-share").syncClients();
} catch {
/* sharing off or not available */
}
}
return { blocked: [...next], ended };
}
/** A flag left by an earlier server (it may still say blocked). */
function hadFlag(userId) {
try {
return fs.existsSync(hostFlag(userId));
} catch {
return false;
}
}
/** Test seam: forget what the sweep remembered. */
function __reset() {
blocked = new Set();
written.clear();
}
module.exports = {
PERIODS,
getDefault,
getOverride,
effective,
setDefault,
setUserQuota,
validateUserQuota,
describe,
windowOf,
slidingReset,
statusAll,
statusFor,
message,
assertSpawnAllowed,
flagEnv,
isRunBlocked,
hookCanStop,
sweep,
__reset,
};
+119 -2
View File
@@ -19,7 +19,8 @@
* (Auth) — Settings → Account: the account's own profile, password and logins
* - GET/PUT/DELETE /api/me/avatar (Auth) — the account's own picture
* - POST /api/me/avatar/url (Auth) — the picture as a link, redirected to
* - GET/PATCH /api/users/{id} (Auth) — the Users page's Edit user form
* - GET/PATCH /api/users/{id} (Auth) — the Users page's Edit user form (its `quota` too)
* - PUT /api/users/quota-default, GET /api/me/quota (Auth) — spend quotas per user
* - GET /api/users/{id}/avatar (Auth) — one account's picture (admin/analyst)
* - GET /api/users/tmux, POST /api/users/{id}/tmux-restart, POST /api/users/tmux-restart-idle
* (Auth) — each member's own tmux server and an admin's restart of it (S3)
@@ -37,6 +38,48 @@ const tags = [
];
const schemas = {
UserQuota: {
type: "object",
description:
"A spend quota: `limit_usd` per `period` — `day` from the local midnight of `tz_offset` (getTimezoneOffset() minutes), or `5h`, the current UTC hour and the four before it.",
required: ["limit_usd", "period"],
properties: {
limit_usd: { type: "number", minimum: 0, exclusiveMinimum: true },
period: { type: "string", enum: ["day", "5h"] },
tz_offset: { type: "integer", minimum: -840, maximum: 840 },
source: { type: "string", enum: ["default", "user"], readOnly: true },
},
},
UserQuotaSetting: {
type: "object",
description:
"A user's own quota setting: `default` (the default applies), `unlimited`, or `custom` with its own limit.",
required: ["mode"],
properties: {
mode: { type: "string", enum: ["default", "unlimited", "custom"] },
limit_usd: { type: "number" },
period: { type: "string", enum: ["day", "5h"] },
tz_offset: { type: "integer" },
},
},
UserQuotaStatus: {
allOf: [
{ $ref: "#/components/schemas/UserQuota" },
{
type: "object",
properties: {
spend: { type: "number", description: "USD spent in the current window" },
pct: { type: "number" },
exceeded: {
type: "boolean",
description: "Used up: new panes are refused (EQUOTA) and prompts refused by the hooks",
},
resetsAt: { type: "string", nullable: true, description: "When it opens again" },
since: { type: "string", description: "Start of the current window" },
},
},
],
},
AccountPin: {
type: "object",
required: ["userId", "username", "account"],
@@ -214,6 +257,32 @@ const paths = {
},
},
},
"/api/me/quota": {
get: {
tags: ["Auth"],
operationId: "getMyQuota",
summary: "The account's own spend quota now (null without one, and in local mode)",
responses: {
200: {
description: "Quota",
content: {
"application/json": {
schema: {
type: "object",
properties: {
quota: {
allOf: [{ $ref: "#/components/schemas/UserQuotaStatus" }],
nullable: true,
},
},
},
},
},
},
401: err("EAUTH"),
},
},
},
"/api/me/instructions": {
get: {
tags: ["Auth"],
@@ -504,11 +573,20 @@ const paths = {
type: "object",
properties: { cost: { type: "number" }, tokens: { type: "integer" } },
},
quotaSetting: { $ref: "#/components/schemas/UserQuotaSetting" },
quota: {
allOf: [{ $ref: "#/components/schemas/UserQuotaStatus" }],
nullable: true,
},
},
},
],
},
},
quotaDefault: {
allOf: [{ $ref: "#/components/schemas/UserQuota" }],
nullable: true,
},
},
},
},
@@ -712,10 +790,11 @@ const paths = {
addWorkspaces: { type: "array", items: { type: "integer" } },
removeWorkspaces: { type: "array", items: { type: "integer" } },
signOut: { type: "boolean", enum: [true] },
quota: { $ref: "#/components/schemas/UserQuotaSetting" },
}),
responses: {
...changeResponses,
400: err("EBADPASSWORD / EBADROLE / EBADFIELD / EBADEMAIL"),
400: err("EBADPASSWORD / EBADROLE / EBADFIELD / EBADEMAIL / EBADQUOTA"),
409: err("ESELF / ENOTOWNED / EOWNERFOLDER"),
},
},
@@ -734,6 +813,44 @@ const paths = {
},
},
},
"/api/users/quota-default": {
put: {
tags: ["Auth"],
operationId: "setDefaultUserQuota",
summary:
"Admin: the default spend quota for every user without one of their own (`quota: null` removes it). A user over their quota can start no pane (429 EQUOTA) and their Claude Code/Codex prompts are refused by the hooks",
requestBody: changeBody(
{
quota: {
allOf: [{ $ref: "#/components/schemas/UserQuota" }],
nullable: true,
},
},
["quota"]
),
responses: {
...changeResponses,
200: {
description: "Saved",
content: {
"application/json": {
schema: {
type: "object",
properties: {
user: { $ref: "#/components/schemas/AuthUser" },
quotaDefault: {
allOf: [{ $ref: "#/components/schemas/UserQuota" }],
nullable: true,
},
},
},
},
},
},
400: err("EBADQUOTA"),
},
},
},
"/api/users/{id}/password": {
post: {
tags: ["Auth"],
+3
View File
@@ -411,6 +411,9 @@ const paths = {
409: errorJson(
"Conflict with live state: ERUNLIVE (the pane is busy), ENOTSHARED, EMEMBERGONE, EOWNERFOLDER or ECWDACCOUNT."
),
429: errorJson(
"EQUOTA (server mode): the workspace owner's spend quota is used up until it resets."
),
503: errorJson("tmux is not installed (ETMUXMISSING)."),
500: errorJson("Unexpected failure (EINTERNAL)."),
},
+4 -1
View File
@@ -3041,11 +3041,14 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
// A refused field (EBADMODEL, EBADEFFORT, …) or a thing the lane's tool
// cannot do (EUNSUPPORTED: a fork on a non-Claude lane) is the request's fault.
// ENOTSHARED (S3): the tool is not under /opt/agenthub/tools yet — a setup state.
// EQUOTA (server mode): the workspace owner is over their spend quota.
const status = /^(EBAD|EUNSUPPORTED$)/.test(err.code || "")
? 400
: ["ENOTSHARED", "EMEMBERGONE", "EOWNERFOLDER", "ECWDACCOUNT"].includes(err.code)
? 409
: 500;
: err.code === "EQUOTA"
? 429
: 500;
return res.status(status).json({ error: { code: err.code, message: err.message } });
}
+12
View File
@@ -36,6 +36,8 @@
* own choice; PUT `{tool, account}` chooses one (`null` = automatic) — refused
* while an admin pins it (`ELOCKED`) or for an account it may not use
* (`ENOACCOUNT`). An account write like the profile's: cookie only, audited.
* `/quota` is the account's own spend quota now (server/lib/user-quota.js —
* `null` without one, and in local mode), for the status bar.
*
* S3 (one Linux user per account): an account's dark/light choice also becomes
* the theme in their own Claude Code settings, written as them.
@@ -170,6 +172,16 @@ router.put("/instructions", sameOriginGuard, (req, res) => {
res.json(instructionsView(key));
});
// The account's own spend quota now (server/lib/user-quota.js): `{quota}` —
// limit, period, spend, whether it is used up and when it opens again — or
// `{quota: null}` with no limit, and always in local mode.
router.get("/quota", (req, res) => {
const key = userKey(req);
if (!key) return unauth(res);
const id = auth.isServerMode() ? (req.user || auth.userFromRequest(req))?.id : null;
res.json({ quota: id == null ? null : require("../lib/user-quota").statusFor(id) });
});
router.post("/onboarded", sameOriginGuard, (req, res) => {
const key = userKey(req);
if (!key) return unauth(res);
+4
View File
@@ -672,6 +672,10 @@ router.post("/", (req, res) => {
) {
return res.status(409).json({ error: { code: err.code, message: err.message } });
}
// Server mode: the workspace owner is over their spend quota (user-quota.js).
if (err.code === "EQUOTA") {
return res.status(429).json({ error: { code: err.code, message: err.message } });
}
// Nor is a host missing tmux: the request was fine, the dependency is not
// installed. 400 would tell the user to fix their input.
if (err.code === "ETMUXMISSING") {
+57 -2
View File
@@ -37,6 +37,12 @@
* non-empty and makes that account pick its own at its next login
* (`must_change_password`); an admin's own new password must pass every
* rule, like Settings → Account's (`validateOwnPassword`, EWEAKPASSWORD).
*
* Spend quotas (server/lib/user-quota.js): `GET /` also gives each account
* its own setting (`quotaSetting`) and the quota binding it now with its spend
* (`quota`), plus the `quotaDefault`; `PUT /quota-default` sets or clears the
* default and `PATCH /:id` `{quota}` a user's own (`default`, `unlimited` or
* `custom`) — account changes like the rest, and each re-sweeps at once.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -130,6 +136,8 @@ router.get("/", (req, res) => {
rules
);
const none = { cost: 0, tokens: 0 };
const quota = require("../lib/user-quota");
const quotas = quota.statusAll();
const users = listUsers().map((u) => ({
...u,
...(logins.get(u.id) || { logins: 0, lastLoginAt: null }),
@@ -138,8 +146,12 @@ router.get("/", (req, res) => {
today: today.get(u.id) || none,
last7d: last7d.get(u.id) || none,
last30d: last30d.get(u.id) || none,
// Spend quota (user-quota.js): this user's own setting, and the quota that
// binds them now with its spend (null: no limit).
quotaSetting: quota.getOverride(u.id),
quota: quotas.get(u.id) || null,
}));
res.json({ users });
res.json({ users, quotaDefault: quota.getDefault() });
});
router.get("/storage", (req, res) => {
@@ -164,6 +176,7 @@ const STATUS = {
EWEAKPASSWORD: 400,
EBADROLE: 400,
EBADFIELD: 400,
EBADQUOTA: 400,
EREAUTH: 403,
ELOCALMODE: 403,
ENOUSER: 404,
@@ -196,7 +209,9 @@ function accountChange(handler) {
await auth.confirmPassword(req.user, body.currentPassword);
const out = await handler(req, body);
recordAccountChange(req.user.id, out.user.id, req.method, out.action);
res.status(out.status || 200).json({ user: out.user });
res
.status(out.status || 200)
.json({ user: out.user, ...("quotaDefault" in out && { quotaDefault: out.quotaDefault }) });
} catch (err) {
const status = STATUS[err.code];
if (!status) return res.status(500).json({ error: { message: err.message } });
@@ -507,6 +522,37 @@ router.post(
})
);
/**
* The default spend quota every user without one of their own gets
* (server/lib/user-quota.js): `{quota: {limit_usd, period, tz_offset}}`, or
* `{quota: null}` for none. The admin's password again, audited.
*/
router.put(
"/quota-default",
accountChange(async (req, body) => {
const quota = require("../lib/user-quota");
if (body.quota === undefined) {
throw Object.assign(new Error("quota: an object or null"), { code: "EBADQUOTA" });
}
const saved = quota.setDefault(body.quota);
quotaSweep();
return {
user: auth.publicUser(db.prepare("SELECT * FROM users WHERE id = ?").get(req.user.id)),
action: `default spend quota: ${saved ? `$${saved.limit_usd}/${saved.period}` : "none"}`,
quotaDefault: saved,
};
})
);
/** Apply a quota change now rather than at the next minute's sweep. */
function quotaSweep() {
try {
require("../lib/user-quota").sweep();
} catch (err) {
console.warn("user quota sweep failed:", err.message);
}
}
router.patch(
"/:id",
accountChange(async (req, body) => {
@@ -542,6 +588,8 @@ router.patch(
if (body.signOut !== undefined && body.signOut !== true) {
throw Object.assign(new Error("signOut: true"), { code: "EBADFIELD" });
}
const quotaLib = require("../lib/user-quota");
const quota = body.quota === undefined ? null : quotaLib.validateUserQuota(body.quota);
const add = laneIds(body.addWorkspaces, "addWorkspaces");
const remove = laneIds(body.removeWorkspaces, "removeWorkspaces");
const runAs = require("../lib/run-as");
@@ -584,6 +632,11 @@ router.patch(
auth.endSessions(u.id, keepToken);
done.push("signed out everywhere");
}
if (quota) {
quotaLib.setUserQuota(u.id, quota);
done.push(`spend quota ${quotaLib.describe(quota)}`);
quotaSweep();
}
if (disabledChange) {
auth.setDisabled(u.username, body.disabled);
done.push(body.disabled ? "disable" : "enable");
@@ -710,3 +763,5 @@ router.post(
);
module.exports = router;
// Which account each session belongs to, for the spend quotas (server/lib/user-quota.js).
module.exports.OWNED_SESSIONS = OWNED_SESSIONS;