Files
AgentHub/server/lib/user-quota.js
T
nntrivi2001 fa27b299e7 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.
2026-10-28 14:31:11 +07:00

468 lines
17 KiB
JavaScript

/**
* @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,
};