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
+119 -2
View File
@@ -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"],