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:
@@ -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)."),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user