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
+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;