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
+38 -1
View File
@@ -545,6 +545,31 @@ export type ServerUserUsage = AuthUser & {
/** Active login sessions (cookies not expired). */
logins?: number;
lastLoginAt?: string | null;
/** This user's own spend quota setting (absent from an older server). */
quotaSetting?: UserQuotaSetting;
/** The quota that binds them now with its spend; null = no limit. */
quota?: UserQuotaStatus | null;
};
/** A spend quota: `limit_usd` per day (from local midnight of `tz_offset`) or per sliding 5 h. */
export type UserQuota = {
limit_usd: number;
period: "day" | "5h";
/** getTimezoneOffset() minutes of the zone a `day` starts in. */
tz_offset?: number;
/** Where it comes from: the default, or the user's own. */
source?: "default" | "user";
};
/** A user's own setting: the default applies, no limit, or a limit of their own. */
export type UserQuotaSetting =
{ mode: "default" } | { mode: "unlimited" } | ({ mode: "custom" } & UserQuota);
/** A quota now (`GET /api/users`, `GET /api/me/quota`). */
export type UserQuotaStatus = UserQuota & {
spend: number;
pct: number;
/** Used up: no new session starts and prompts are refused until `resetsAt`. */
exceeded: boolean;
resetsAt: string | null;
since: string;
};
/** A priced slice of a user's spend (`GET /api/users/:id/details`). */
export type UsageSlice = { key: string; cost: number; tokens: number; sessions: number };
@@ -773,6 +798,8 @@ export type UserPatch = {
addWorkspaces?: number[];
removeWorkspaces?: number[];
signOut?: true;
/** Their own spend quota (`default` = the default applies). */
quota?: UserQuotaSetting;
};
/** GET /api/me/account — the signed-in account's own information. */
export type MyAccount =
@@ -1210,6 +1237,8 @@ export const api = {
/** PUT /api/me/prefs — merge; `null` deletes a key. */
savePrefs: (prefs: Record<string, string | null>) =>
request<UserPrefs>("/me/prefs", { method: "PUT", body: JSON.stringify({ prefs }) }),
/** GET /api/me/quota — this account's own spend quota now (null: no limit, or local mode). */
quota: () => request<{ quota: UserQuotaStatus | null }>("/me/quota"),
/** GET /api/me/instructions — this account's own agent instructions (issue #67). */
instructions: () => request<AgentInstructionsView>("/me/instructions"),
/** PUT /api/me/instructions — blank clears; put before every workspace's own. */
@@ -1271,7 +1300,15 @@ export const api = {
avatarAt ? `${BASE}/users/${id}/avatar?v=${encodeURIComponent(avatarAt)}` : null,
/** GET /api/users — every account with its recent usage (admin/analyst). */
list: () =>
request<{ users: ServerUserUsage[] }>(`/users?tz_offset=${new Date().getTimezoneOffset()}`),
request<{ users: ServerUserUsage[]; quotaDefault?: UserQuota | null }>(
`/users?tz_offset=${new Date().getTimezoneOffset()}`
),
/** PUT /api/users/quota-default — the default spend quota (null = none). */
setQuotaDefault: (quota: UserQuota | null, currentPassword: string) =>
request<{ user: AuthUser; quotaDefault: UserQuota | null }>("/users/quota-default", {
method: "PUT",
body: JSON.stringify({ quota, currentPassword }),
}),
/** GET /api/users/:id/details — one account in full (admin/analyst). */
details: (id: number) =>
request<UserDetails>(`/users/${id}/details?tz_offset=${new Date().getTimezoneOffset()}`),