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