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:
+38
-1
@@ -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()}`),
|
||||
|
||||
Reference in New Issue
Block a user