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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
{
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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"],
|
||||
|
||||
@@ -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)."),
|
||||
},
|
||||
|
||||
@@ -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