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:
@@ -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 } });
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user