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:
@@ -327,6 +327,7 @@ graph TD
|
||||
| `routes/settings.js` | System info (DB size, hook status, server uptime, transcript cache stats), data export as one versioned JSON bundle and matching import/restore (`POST /api/settings/import` via `server/lib/data-transfer.js` — idempotent, session-atomic, non-destructive; consolidates machines), session cleanup (abandon stale, purge old), clear all data (including the fired-alert feed and webhook delivery log; alert *rules* and webhook *targets* are preserved as user configuration), reset pricing, reinstall hooks |
|
||||
| `routes/alerts.js` | HTTP surface for the rules-based alerting engine: alert-rule CRUD (`GET/POST /api/alerts/rules`, `PATCH/DELETE /api/alerts/rules/:id` — rule_type is immutable after creation, config re-validated against the stored type on PATCH), the fired-alert feed (`GET /api/alerts` with `?unacked=true` + pagination, response carries `total` and `unacked` counts), and acknowledgement (`POST /api/alerts/:id/ack`, `POST /api/alerts/ack-all`, broadcasting `alert_updated`), and the read-only dry run `POST /api/alerts/rules/preview` (`{rule_type, config, days?, cooldown_seconds?}` → what `previewRule` says it would have fired over the last `days`, 1–90, default 7). Every rule mutation calls `invalidateRuleCache()` so the evaluation engine picks up changes immediately |
|
||||
| `routes/budgets.js` + `lib/budgets.js` | Spend budgets: a budget IS an alert rule of type `budget` (CRUD through `/api/alerts/rules`). `lib/budgets.js` computes a period's bounds in the user's local time (`tz_offset` saved with the rule; day / week from Monday / month), its spend priced like `GET /api/pricing/cost` by the day tokens were spent (via `dailyTokenRows` + `costWhere`, scoped to `tool`/`lane`), and the pace projection to the period's end; `GET /api/budgets/status` lists every enabled budget with `spend`, `pct`, `projected` and the thresholds `crossed` |
|
||||
| `lib/user-quota.js` | Server mode's spend quota per user (`user_quotas`, row 0 = the default; `PUT /api/users/quota-default`, `PATCH /api/users/:id {quota}`, `GET /api/me/quota`): `limit_usd` per local `day` or sliding `5h`, spend = the users page's `hourlyTokenRows` + `calculateCost` over owned sessions. Over it: `spawnRun` throws `EQUOTA` (429); a 60 s `sweep` writes each pane's `AGENTHUB_QUOTA_FILE` (the hook handler then exits 2 on `UserPromptSubmit`, from the file alone, fail-open), leaves the owner's omp keys out of the gateway's clients file and ends panes no hook can stop |
|
||||
| `lib/alerts.js` | Rule evaluation engine for the alerting feature. Six rule types — `cost_threshold` (a session's cost ≥ `usd`, on token-bearing events like `token_threshold`) and `budget` (on the sweep: each of `thresholds` % of `limit_usd` for the current `period` fires ONCE per period, deduped on `details.period_start` + `details.threshold` and bypassing the cooldown) plus the four original ones: `event_pattern` (match `event_type` / `tool_name` / `summary_contains`, optionally requiring ≥ `count` matching events within `window_minutes` — counted via a dynamically built, statement-cached SQL query), `token_threshold` (session total tokens ≥ `total_tokens`, only evaluated on token-bearing events: PostToolUse / Stop / SubagentStop / SessionEnd), `inactivity` (active session whose `updated_at` — bumped on every ingested event — is older than `minutes`), and `status_duration` (agent stuck in `working`/`waiting` with no activity for `minutes`, joined against active sessions). Event-driven types run via `evaluateEvent()` called from `routes/hooks.js` **after** the ingest transaction commits and the HTTP response is sent — alerting can never slow down or fail hook ingestion, and `evaluateEvent` is itself fully try/catch-guarded per rule. Time-based types run via `sweepTimeRules()` on a 60 s unref'd interval (same pattern as the hooks watchdog). `fireAlert()` applies per-(rule, session, agent) cooldown dedup (`cooldown_seconds`, default 300) by checking the most recent `alert_events` row for the scope, then persists and broadcasts `alert_triggered`. Enabled rules are cached in memory (hook ingest is hot) and invalidated on every CRUD mutation. `validateRuleConfig()` normalizes + validates type-specific config and is shared with the routes. After persisting and broadcasting a fired alert, `fireAlert()` hands it to `lib/webhooks.js` `dispatchAlert()` fire-and-forget (lazy-required to keep the module graph acyclic) — webhook delivery never blocks or fails alert firing |
|
||||
| `routes/webhooks.js` | HTTP surface for universal webhook targets: target CRUD (`GET/POST /api/webhooks`, `PATCH/DELETE /api/webhooks/:id` — `type` is immutable after creation), a redacted provider catalog (`GET /api/webhooks/providers`, drives the UI form), a synchronous test probe (`POST /api/webhooks/:id/test` — always 200, the `ok` flag carries the downstream delivery result), and a per-target delivery log (`GET /api/webhooks/:id/deliveries`). Validation is registry-driven: required URL (per provider), per-provider config fields, https enforcement, generic-family secret/headers. **Security**: target URLs are masked (host + last 4 chars) and secret config fields + custom-header values are redacted in every response — full URLs, signing secrets, and credentials (routing keys, api keys, bot tokens) are stored server-side and never leave the server. PATCH uses "set-flag" semantics (omit `url`/`secret`/`headers`/`config` to leave unchanged); `config` is merged over the existing config so one field can change without re-sending secrets. Every mutation calls `invalidateWebhookCache()` |
|
||||
| `lib/webhook-providers.js`| Declarative registry of the 14 first-class providers (+ generic). Each entry declares a `family` (`chat` / `api` / `generic`), a payload `format`ter, URL resolution (`urlFrom(config)` for Telegram/Opsgenie that derive the endpoint, `defaultUrl` for PagerDuty, or a user-supplied URL), optional `authFrom(config)` headers (Opsgenie GenieKey), and the credential `fields` the UI renders + the route validates. Formatters emit each platform's native body: Slack Block Kit, Discord embed, Teams Adaptive Card wrapped in the Power Automate Workflows `{ type: "message", attachments: [...] }` envelope (the legacy O365-connector MessageCard transport was retired May 2026), Google Chat text, Mattermost/Rocket.Chat Slack-style attachments, Telegram sendMessage (HTML), PagerDuty Events API v2 (with `dedup_key`), Opsgenie Alert API, Splunk On-Call/VictorOps, and the generic `{ event, alert }` envelope. A provider may also declare `verifyResponse(body)` to veto a 2xx that actually signals failure (Splunk On-Call returns 200 with `result:"failure"`). `publicProviders()` returns the redacted catalog. Adding a provider = one registry entry + a formatter — no route or delivery changes |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Claude Code Working Guide
|
||||
|
||||
_Last synced: v2.8.30 (2026-10-28) — the account pool is one board of pools (split/merge by drag), and a Claude plan is read from its tier and seat._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
|
||||
_Last synced: v2.8.30 (2026-10-28) — spend quotas per user (server mode): EQUOTA at start, prompts refused by the hook's local flag file._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
|
||||
|
||||
## Project mission
|
||||
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
|
||||
@@ -31,6 +31,7 @@ A **workspace** — called a **lane** in the API, CLI, database and code, which
|
||||
- **Push ingest takes only what a token holder may claim.** `POST /api/hooks/ingest-batch` answers 503 until a token exists; the token (`server/lib/push-token.js`) is shown ONCE and stored as a SHA-256 hash, accepted only from the `Authorization: Bearer` header and compared in constant time. A pushed hook may extend only a session its own `push:<source>` created (`EFOREIGN`), its `transcript_path` is dropped so no local file is ever read from it, and it never touches a lane or a pane. Do not add a GET that returns the token or a route that takes it from a query string.
|
||||
- **A snapshot is immutable and named by an unguessable token.** `session_snapshots` rows are written once (a trigger refuses UPDATE) under 24 random bytes base64url; redaction is applied AT CAPTURE so a stored blob can never be un-redacted; writes are same-origin. Do not add an update route or a token derived from the session id.
|
||||
- **A budget is an alert rule, not a parallel system.** `budget` and `cost_threshold` live in `alert_rules` (validated by `validateRuleConfig`, the table has no rule_type CHECK any more) and fire through `fireAlert` → feed, webhooks, push; a budget fires each threshold ONCE per period (deduped on `details.period_start` + `details.threshold`). Spend is always `dailyTokenRows` + `calculateCost` — never a second cost formula. `POST /api/alerts/rules/preview` may never write, fire or broadcast.
|
||||
- **A spend quota is enforcement, decided locally, and the hook stays fail-open.** `server/lib/user-quota.js` (server mode only; `user_quotas`, row 0 = the default): an owner over their quota gets `EQUOTA` (429) from `spawnRun`, and the minute `sweep` writes `AGENTHUB_QUOTA_FILE` (host: `<data dir>/user-quota/u<id>.json`; member: their handoff dir through `write-as`), drops a blocked owner's omp gateway keys (`isRunBlocked`) and ends only panes no hook can stop (`hookCanStop`). `hook-handler.js` refuses a `UserPromptSubmit` (exit 2) from that FILE alone — never by waiting on the dashboard, never for Antigravity, and a missing/bad/expired flag lets the prompt through. Spend is the users page's `hourlyTokenRows` + `calculateCost`, never a second formula.
|
||||
- **A report file path is built from ids only.** `server/lib/reports.js` writes under `<data dir>/reports/<id>/` and the download route resolves a run by its ids; no route takes a path or file name from a request, and every value in a report's HTML is escaped.
|
||||
- **AgentHub ships no third-party pet art, and a pet comes only from an upload or a fixed featured id.** The featured pets are codex-pets.net uploads with no redistribution licence (one is a copyrighted character), so `server/lib/pets.js` downloads them into the user's data dir on a pick — never commit their files, and never add a pet route that takes a URL or a path. Uploads are raster only (an SVG served from the dashboard's origin runs script), a bundle's entries are size-checked from the zip directory before they are inflated, and pet ids are made by the server.
|
||||
- **AgentHub does not orchestrate:** no chaining, no queue, no retry logic, no gate evaluation. The session in control makes all decisions; the dashboard records the claimed stage and shows evidence. The runtime layer (`agenthub lanes up|down|hook`) does not change this — it offers *primitives* a session calls; nothing in the dashboard sequences them.
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
* its fullest window, live account only — and, on hover or focus, a panel
|
||||
* above grouping every account per tool (saved Claude profiles too): name,
|
||||
* sign-in expiry, and a row per window with its bar, percent and reset time.
|
||||
* Server mode: the user's OWN spend quota (`GET /api/me/quota`, read with the
|
||||
* costs) as "Quota N%" — amber from 80 %, red once used up, when the user can
|
||||
* start nothing and their prompts are refused until it opens again.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -30,9 +33,9 @@ import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { RefreshCw, Wifi, WifiOff } from "lucide-react";
|
||||
import { api, type RunHandle } from "../lib/api";
|
||||
import { api, type RunHandle, type UserQuotaStatus } from "../lib/api";
|
||||
import type { BudgetStatus, CostResult, ToolQuota } from "../lib/types";
|
||||
import { fmt, fmtCostFull, formatModelName, timeAgo } from "../lib/format";
|
||||
import { fmt, fmtCostFull, formatDateTime, formatModelName, timeAgo } from "../lib/format";
|
||||
import { AiToolIcon } from "./AiToolIcon";
|
||||
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
||||
import { useSpin } from "../hooks/useSpin";
|
||||
@@ -369,6 +372,8 @@ export function StatusBar({
|
||||
const [runs, setRuns] = useState<RunHandle[]>([]);
|
||||
// The budget furthest past one of its thresholds, if any.
|
||||
const [budget, setBudget] = useState<BudgetStatus | null>(null);
|
||||
// Server mode: this user's own spend quota (null: none, or local mode).
|
||||
const [myQuota, setMyQuota] = useState<UserQuotaStatus | null>(null);
|
||||
const location = useLocation();
|
||||
const shown = selectedFrom(location.pathname, location.search);
|
||||
const lane = shown.lane;
|
||||
@@ -414,6 +419,10 @@ export function StatusBar({
|
||||
if (live()) setBudget(hit[0] ?? null);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
api.me
|
||||
.quota()
|
||||
.then((r) => live() && setMyQuota(r?.quota ?? null))
|
||||
.catch(() => undefined);
|
||||
if (lane !== null) {
|
||||
api.pricing
|
||||
.totalCost({ lane })
|
||||
@@ -524,6 +533,26 @@ export function StatusBar({
|
||||
{t("statusBar.budget", { pct: Math.round(budget.pct) })}
|
||||
</Link>
|
||||
)}
|
||||
{myQuota && (
|
||||
<span
|
||||
data-testid="status-bar-user-quota"
|
||||
title={t(myQuota.exceeded ? "statusBar.userQuotaBlocked" : "statusBar.userQuotaTitle", {
|
||||
spend: usd(myQuota.spend),
|
||||
limit: usd(myQuota.limit_usd),
|
||||
period: t(`statusBar.userQuotaPeriod.${myQuota.period}`),
|
||||
at: myQuota.resetsAt ? formatDateTime(myQuota.resetsAt) : "",
|
||||
})}
|
||||
className={`tabular-nums ${
|
||||
myQuota.exceeded
|
||||
? "text-status-danger"
|
||||
: myQuota.pct >= 80
|
||||
? "text-status-warning"
|
||||
: "text-fg-muted"
|
||||
}`}
|
||||
>
|
||||
{t("statusBar.userQuota", { pct: Math.min(999, Math.round(myQuota.pct)) })}
|
||||
</span>
|
||||
)}
|
||||
<QuotaChips />
|
||||
</footer>
|
||||
);
|
||||
|
||||
@@ -129,6 +129,20 @@ vi.mock("../../lib/api", () => ({
|
||||
daily_costs: [],
|
||||
})),
|
||||
},
|
||||
me: {
|
||||
// Server mode: this user's own spend quota, used up.
|
||||
quota: vi.fn().mockResolvedValue({
|
||||
quota: {
|
||||
limit_usd: 10,
|
||||
period: "day",
|
||||
spend: 12,
|
||||
pct: 120,
|
||||
exceeded: true,
|
||||
resetsAt: "2026-10-29T17:00:00.000Z",
|
||||
since: "2026-10-28T17:00:00.000Z",
|
||||
},
|
||||
}),
|
||||
},
|
||||
budgets: {
|
||||
status: vi.fn().mockResolvedValue({
|
||||
budgets: [
|
||||
@@ -183,6 +197,16 @@ describe("StatusBar", () => {
|
||||
expect(chip.getAttribute("title")).toBe("Monthly: $85.00 of $100.00 this period");
|
||||
});
|
||||
|
||||
it("shows the user's own spend quota, red once used up", async () => {
|
||||
at("/", <StatusBar wsConnected left="0" onConnectionClick={() => {}} />);
|
||||
const chip = await screen.findByTestId("status-bar-user-quota");
|
||||
expect(chip.textContent).toBe("Quota 120%");
|
||||
expect(chip.className).toContain("text-status-danger");
|
||||
expect(chip.getAttribute("title")).toMatch(
|
||||
/^Your spend quota is used up \(\$12\.00 of \$10\.00 today\)/
|
||||
);
|
||||
});
|
||||
|
||||
it("says Disconnected when the socket is down", () => {
|
||||
at("/", <StatusBar wsConnected={false} left="0" onConnectionClick={() => {}} />);
|
||||
expect(screen.getByRole("button", { name: /Disconnected/ })).toBeInTheDocument();
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
* account), a new password (typed or generated, copyable — someone else's
|
||||
* may be anything and makes them choose their own at next sign-in; your own
|
||||
* must pass every rule of `lib/passwordRules.ts`), the workspaces the
|
||||
* account owns (remove, add from every workspace), and "sign out everywhere".
|
||||
* account owns (remove, add from every workspace), its spend quota (the
|
||||
* default, no limit, or its own dollars per day / 5 h — `UserQuota.tsx`), and
|
||||
* "sign out everywhere".
|
||||
* Every change is staged and sent as ONE `PATCH /api/users/:id` carrying only
|
||||
* what changed, confirmed with the admin's OWN password (`currentPassword`) —
|
||||
* the server validates the whole change before it applies any of it. A
|
||||
@@ -17,7 +19,14 @@ import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNod
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Copy, Eye, EyeOff, Plus, Sparkles, X } from "lucide-react";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { api, type AuthUser, type ServerUserUsage, type UserPatch } from "../../lib/api";
|
||||
import {
|
||||
api,
|
||||
type AuthUser,
|
||||
type ServerUserUsage,
|
||||
type UserPatch,
|
||||
type UserQuota,
|
||||
type UserQuotaSetting,
|
||||
} from "../../lib/api";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { fmtCost, formatDateTime } from "../../lib/format";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
@@ -27,6 +36,7 @@ import { PasswordRules } from "./PasswordRules";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { Switch } from "../Switch";
|
||||
import { Checkbox } from "../Checkbox";
|
||||
import { QuotaFields, parseLimit, useQuotaLabel, type QuotaPeriod } from "./UserQuota";
|
||||
|
||||
type Role = AuthUser["role"];
|
||||
const ROLES: Role[] = ["admin", "analyst", "developer"];
|
||||
@@ -55,10 +65,13 @@ export function UserEditDialog({
|
||||
user,
|
||||
self,
|
||||
users,
|
||||
quotaDefault = null,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
user: ServerUserUsage;
|
||||
/** The default spend quota, named beside the "Default" choice. */
|
||||
quotaDefault?: UserQuota | null;
|
||||
/** The account being edited is the signed-in admin's own. */
|
||||
self: boolean;
|
||||
users: ServerUserUsage[];
|
||||
@@ -81,6 +94,18 @@ export function UserEditDialog({
|
||||
const [added, setAdded] = useState<number[]>([]);
|
||||
const [pick, setPick] = useState("");
|
||||
const [signOut, setSignOut] = useState(false);
|
||||
// Spend quota: absent from an older server, which then gets no quota field.
|
||||
const quotaSetting = user.quotaSetting;
|
||||
const [quotaMode, setQuotaMode] = useState<UserQuotaSetting["mode"]>(
|
||||
quotaSetting?.mode ?? "default"
|
||||
);
|
||||
const [quotaLimit, setQuotaLimit] = useState(
|
||||
quotaSetting?.mode === "custom" ? String(quotaSetting.limit_usd) : ""
|
||||
);
|
||||
const [quotaPeriod, setQuotaPeriod] = useState<QuotaPeriod>(
|
||||
quotaSetting?.mode === "custom" ? quotaSetting.period : (quotaDefault?.period ?? "day")
|
||||
);
|
||||
const quotaLabel = useQuotaLabel();
|
||||
const [current, setCurrent] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -117,12 +142,33 @@ export function UserEditDialog({
|
||||
if (added.length) patch.addWorkspaces = added;
|
||||
if (removed.size) patch.removeWorkspaces = [...removed];
|
||||
if (signOut) patch.signOut = true;
|
||||
const quotaValue = parseLimit(quotaLimit);
|
||||
const quotaBad = quotaMode === "custom" && quotaValue === null;
|
||||
if (quotaSetting && !quotaBad) {
|
||||
const same =
|
||||
quotaMode === quotaSetting.mode &&
|
||||
(quotaMode !== "custom" ||
|
||||
(quotaSetting.mode === "custom" &&
|
||||
quotaSetting.limit_usd === quotaValue &&
|
||||
quotaSetting.period === quotaPeriod));
|
||||
if (!same) {
|
||||
patch.quota =
|
||||
quotaMode === "custom"
|
||||
? {
|
||||
mode: "custom",
|
||||
limit_usd: quotaValue as number,
|
||||
period: quotaPeriod,
|
||||
tz_offset: new Date().getTimezoneOffset(),
|
||||
}
|
||||
: { mode: quotaMode };
|
||||
}
|
||||
}
|
||||
const changed = Object.keys(patch).length > 0;
|
||||
// A blank field keeps the password. Someone else's may be anything (they
|
||||
// choose their own at next sign-in); your own must pass every rule.
|
||||
const weakOwn =
|
||||
self && password.length > 0 && passwordProblems(password, user.username).length > 0;
|
||||
const ready = changed && current.length > 0 && !weakOwn && !busy;
|
||||
const ready = changed && current.length > 0 && !weakOwn && !quotaBad && !busy;
|
||||
|
||||
const submit = async () => {
|
||||
if (!ready) return;
|
||||
@@ -479,6 +525,44 @@ export function UserEditDialog({
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{quotaSetting && (
|
||||
<Section title={t("quota.title")}>
|
||||
<div className="space-y-2" data-testid="user-edit-quota">
|
||||
<select
|
||||
className="input w-full text-sm"
|
||||
value={quotaMode}
|
||||
onChange={(e) => setQuotaMode(e.target.value as UserQuotaSetting["mode"])}
|
||||
aria-label={t("quota.title")}
|
||||
>
|
||||
<option value="default">
|
||||
{quotaDefault
|
||||
? t("quota.modes.defaultOf", { quota: quotaLabel(quotaDefault) })
|
||||
: t("quota.modes.defaultNone")}
|
||||
</option>
|
||||
<option value="unlimited">{t("quota.modes.unlimited")}</option>
|
||||
<option value="custom">{t("quota.modes.custom")}</option>
|
||||
</select>
|
||||
{quotaMode === "custom" && (
|
||||
<QuotaFields
|
||||
limit={quotaLimit}
|
||||
period={quotaPeriod}
|
||||
onLimit={setQuotaLimit}
|
||||
onPeriod={setQuotaPeriod}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
)}
|
||||
<span className="block text-[11px] text-fg-muted">
|
||||
{user.quota
|
||||
? t("quota.now", {
|
||||
spend: fmtCost(user.quota.spend),
|
||||
limit: quotaLabel(user.quota),
|
||||
})
|
||||
: t("quota.enforceHint")}
|
||||
</span>
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section title={t("edit.sessions")}>
|
||||
<Checkbox
|
||||
checked={signOut}
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* @file UserQuota.tsx
|
||||
* @description Spend quotas per user on the Users page (server mode;
|
||||
* server/lib/user-quota.js): `QuotaSummary`, a row's "spent / limit per day"
|
||||
* line with its bar — red once used up, with when it opens again;
|
||||
* `QuotaFields`, a limit in dollars and its period (a day from local midnight,
|
||||
* or a sliding 5 hours); and `QuotaDefaultCard`, the default every user
|
||||
* without one of their own gets — an admin edits it with their own password
|
||||
* again (`PUT /api/users/quota-default`), an analyst only reads. A user's own
|
||||
* setting is part of the Edit user form (`UserEditDialog`). A user over their
|
||||
* quota can start no session and their prompts are refused until it resets.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState, type KeyboardEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Gauge, Pencil } from "lucide-react";
|
||||
import { api, type UserQuota, type UserQuotaStatus } from "../../lib/api";
|
||||
import { fmtCost, formatDateTime } from "../../lib/format";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { Checkbox } from "../Checkbox";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
import { Meter } from "../health/HostHealth";
|
||||
|
||||
export type QuotaPeriod = UserQuota["period"];
|
||||
export const QUOTA_PERIODS: QuotaPeriod[] = ["day", "5h"];
|
||||
|
||||
/** "$10 / day", "$2.50 / 5 h". */
|
||||
export function useQuotaLabel() {
|
||||
const { t } = useTranslation("users");
|
||||
return (q: Pick<UserQuota, "limit_usd" | "period">) =>
|
||||
t(`quota.per.${q.period}`, { limit: fmtCost(q.limit_usd) });
|
||||
}
|
||||
|
||||
/** A row's quota: spent of the limit, a bar, and when a used-up one opens again. */
|
||||
export function QuotaSummary({ quota }: { quota: UserQuotaStatus | null | undefined }) {
|
||||
const { t } = useTranslation("users");
|
||||
const label = useQuotaLabel();
|
||||
if (quota === undefined) return null;
|
||||
if (quota === null) {
|
||||
return <div className="text-[11px] text-fg-muted">{t("quota.none")}</div>;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="mt-1 space-y-0.5"
|
||||
data-testid="user-quota"
|
||||
title={
|
||||
quota.exceeded && quota.resetsAt
|
||||
? t("quota.blockedUntil", { at: formatDateTime(quota.resetsAt) })
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={`whitespace-nowrap text-[11px] ${quota.exceeded ? "text-status-danger" : "text-fg-muted"}`}
|
||||
>
|
||||
{t("quota.used", { spend: fmtCost(quota.spend), limit: label(quota) })}
|
||||
{quota.source === "default" && <span className="ml-1">{t("quota.defaultTag")}</span>}
|
||||
</div>
|
||||
<Meter value={quota.pct} label={t("quota.title")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A dollar limit and its period. `limit` is the raw input text. */
|
||||
export function QuotaFields({
|
||||
limit,
|
||||
period,
|
||||
onLimit,
|
||||
onPeriod,
|
||||
onKeyDown,
|
||||
disabled,
|
||||
}: {
|
||||
limit: string;
|
||||
period: QuotaPeriod;
|
||||
onLimit: (v: string) => void;
|
||||
onPeriod: (v: QuotaPeriod) => void;
|
||||
onKeyDown?: (e: KeyboardEvent<HTMLInputElement>) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<div className="relative w-36">
|
||||
<span className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-sm text-fg-muted">
|
||||
$
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
inputMode="decimal"
|
||||
className="input w-full pl-6 text-sm tabular-nums"
|
||||
value={limit}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onLimit(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
aria-label={t("quota.limit")}
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
className="input flex-1 text-sm"
|
||||
value={period}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onPeriod(e.target.value as QuotaPeriod)}
|
||||
aria-label={t("quota.period")}
|
||||
>
|
||||
{QUOTA_PERIODS.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{t(`quota.periods.${p}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A positive dollar amount from the input text, or null. */
|
||||
export function parseLimit(text: string): number | null {
|
||||
const n = Number(text);
|
||||
return text.trim() !== "" && Number.isFinite(n) && n > 0 ? Math.round(n * 100) / 100 : null;
|
||||
}
|
||||
|
||||
/** The default quota: shown to staff, edited by an admin with their own password. */
|
||||
export function QuotaDefaultCard({
|
||||
quotaDefault,
|
||||
isAdmin,
|
||||
onSaved,
|
||||
}: {
|
||||
/** `undefined` while loading, null for none. */
|
||||
quotaDefault: UserQuota | null | undefined;
|
||||
isAdmin: boolean;
|
||||
onSaved: (message: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const label = useQuotaLabel();
|
||||
const [editing, setEditing] = useState(false);
|
||||
return (
|
||||
<section className="card" data-testid="quota-default-card">
|
||||
<div className="flex flex-wrap items-center gap-3 px-5 py-4">
|
||||
<Gauge className="h-4 w-4 text-fg-muted" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-sm font-semibold text-fg-primary">{t("quota.cardTitle")}</h2>
|
||||
<p className="text-xs text-fg-muted">{t("quota.cardDescription")}</p>
|
||||
</div>
|
||||
<div className="text-sm tabular-nums text-fg-primary" data-testid="quota-default-value">
|
||||
{quotaDefault === undefined
|
||||
? "…"
|
||||
: quotaDefault
|
||||
? label(quotaDefault)
|
||||
: t("quota.noDefault")}
|
||||
</div>
|
||||
{isAdmin && quotaDefault !== undefined && (
|
||||
<button type="button" className="btn-ghost text-xs" onClick={() => setEditing(true)}>
|
||||
<Pencil className="h-3.5 w-3.5" /> {t("quota.editDefault")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{editing && (
|
||||
<QuotaDefaultDialog
|
||||
current={quotaDefault ?? null}
|
||||
onClose={() => setEditing(false)}
|
||||
onDone={(message) => {
|
||||
setEditing(false);
|
||||
onSaved(message);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function QuotaDefaultDialog({
|
||||
current,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
current: UserQuota | null;
|
||||
onClose: () => void;
|
||||
onDone: (message: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const [on, setOn] = useState(current !== null);
|
||||
const [limit, setLimit] = useState(current ? String(current.limit_usd) : "");
|
||||
const [period, setPeriod] = useState<QuotaPeriod>(current?.period ?? "day");
|
||||
const [password, setPassword] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const value = parseLimit(limit);
|
||||
const ready = password.length > 0 && (!on || value !== null) && !busy;
|
||||
|
||||
const submit = async () => {
|
||||
if (!ready) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.users.setQuotaDefault(
|
||||
on && value !== null
|
||||
? { limit_usd: value, period, tz_offset: new Date().getTimezoneOffset() }
|
||||
: null,
|
||||
password
|
||||
);
|
||||
onDone(t("quota.defaultSaved"));
|
||||
} catch (e) {
|
||||
const err = e as Error & { code?: string };
|
||||
setError(
|
||||
err.code && t(`errors.${err.code}`, { defaultValue: "" })
|
||||
? t(`errors.${err.code}`)
|
||||
: err.message
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
open
|
||||
title={t("quota.defaultDialog")}
|
||||
confirmLabel={t("save")}
|
||||
cancelLabel={t("common:cancel")}
|
||||
destructive={false}
|
||||
busy={busy}
|
||||
disabled={!ready}
|
||||
onConfirm={() => void submit()}
|
||||
onCancel={onClose}
|
||||
>
|
||||
<div className="mt-3 space-y-3">
|
||||
<Checkbox
|
||||
checked={on}
|
||||
onChange={setOn}
|
||||
label={t("quota.defaultOn")}
|
||||
labelClassName="text-sm text-fg-secondary"
|
||||
/>
|
||||
<QuotaFields
|
||||
limit={limit}
|
||||
period={period}
|
||||
onLimit={setLimit}
|
||||
onPeriod={setPeriod}
|
||||
onKeyDown={onKeyDown}
|
||||
disabled={!on}
|
||||
/>
|
||||
<p className="text-[11px] text-fg-muted">{t("quota.enforceHint")}</p>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-fg-secondary">{t("yourPassword")}</span>
|
||||
<input
|
||||
type="password"
|
||||
className="input mt-1 w-full text-sm"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
autoComplete="current-password"
|
||||
aria-label={t("yourPassword")}
|
||||
/>
|
||||
</label>
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</ConfirmModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* @file UserQuota.test.tsx
|
||||
* @description Spend quotas on the Users page: a row's "spent of limit" line
|
||||
* (red once used up, "(default)" when it is the default), the default quota
|
||||
* card (an admin edits it with their own password, an analyst only reads),
|
||||
* and the Edit user form sending a user's own quota as part of its ONE PATCH.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { ServerUserUsage } from "../../../lib/api";
|
||||
|
||||
const users = vi.hoisted(() => ({
|
||||
setQuotaDefault: vi.fn(),
|
||||
update: vi.fn(),
|
||||
get: vi.fn(),
|
||||
}));
|
||||
const lanes = vi.hoisted(() => ({ list: vi.fn() }));
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return { ...actual, api: { users, lanes } };
|
||||
});
|
||||
|
||||
import { QuotaDefaultCard, QuotaSummary } from "../UserQuota";
|
||||
import { UserEditDialog } from "../UserEditDialog";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
users.setQuotaDefault.mockResolvedValue({ user: {}, quotaDefault: null });
|
||||
users.update.mockResolvedValue({ user: {} });
|
||||
users.get.mockResolvedValue({ user: {}, workspaces: [] });
|
||||
lanes.list.mockResolvedValue({ lanes: [] });
|
||||
});
|
||||
|
||||
describe("QuotaSummary", () => {
|
||||
it("shows spent of the limit, red and marked default once used up", () => {
|
||||
render(
|
||||
<QuotaSummary
|
||||
quota={{
|
||||
limit_usd: 10,
|
||||
period: "day",
|
||||
source: "default",
|
||||
spend: 12.5,
|
||||
pct: 125,
|
||||
exceeded: true,
|
||||
resetsAt: "2026-10-29T17:00:00.000Z",
|
||||
since: "2026-10-28T17:00:00.000Z",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const line = screen.getByText(/of \$10\.00 \/ day/);
|
||||
expect(line.textContent).toContain("$12.50");
|
||||
expect(line.textContent).toContain("(default)");
|
||||
expect(line.className).toContain("text-status-danger");
|
||||
expect(screen.getByRole("meter")).toHaveAttribute("aria-valuenow", "100");
|
||||
});
|
||||
|
||||
it("says No quota without one, and nothing for an older server", () => {
|
||||
const { container, rerender } = render(<QuotaSummary quota={null} />);
|
||||
expect(screen.getByText("No quota")).toBeInTheDocument();
|
||||
rerender(<QuotaSummary quota={undefined} />);
|
||||
expect(container.textContent).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("QuotaDefaultCard", () => {
|
||||
it("an admin sets the default with their own password", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSaved = vi.fn();
|
||||
render(<QuotaDefaultCard quotaDefault={null} isAdmin onSaved={onSaved} />);
|
||||
expect(screen.getByTestId("quota-default-value").textContent).toBe("No default — no limit");
|
||||
await user.click(screen.getByRole("button", { name: /Edit/ }));
|
||||
await user.click(screen.getByRole("checkbox", { name: "Limit what each user may spend" }));
|
||||
await user.type(screen.getByLabelText("Limit in dollars"), "25");
|
||||
await user.selectOptions(screen.getByLabelText("Period"), "5h");
|
||||
const save = screen.getByRole("button", { name: "Save" });
|
||||
expect(save).toBeDisabled(); // no password yet
|
||||
await user.type(screen.getByLabelText("Your password"), "pw");
|
||||
await user.click(save);
|
||||
await waitFor(() => expect(onSaved).toHaveBeenCalled());
|
||||
expect(users.setQuotaDefault).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ limit_usd: 25, period: "5h" }),
|
||||
"pw"
|
||||
);
|
||||
});
|
||||
|
||||
it("an analyst only reads it", () => {
|
||||
render(
|
||||
<QuotaDefaultCard
|
||||
quotaDefault={{ limit_usd: 5, period: "day" }}
|
||||
isAdmin={false}
|
||||
onSaved={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("quota-default-value").textContent).toBe("$5.00 / day");
|
||||
expect(screen.queryByRole("button", { name: /Edit/ })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("UserEditDialog quota", () => {
|
||||
const mia: ServerUserUsage = {
|
||||
id: 7,
|
||||
username: "mia",
|
||||
role: "developer",
|
||||
disabled: false,
|
||||
workspaces: 0,
|
||||
sessions: 0,
|
||||
last7d: { cost: 0, tokens: 0 },
|
||||
last30d: { cost: 0, tokens: 0 },
|
||||
quotaSetting: { mode: "default" },
|
||||
quota: null,
|
||||
} as ServerUserUsage;
|
||||
|
||||
it("sends the user's own limit in the one PATCH", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onDone = vi.fn();
|
||||
render(
|
||||
<UserEditDialog
|
||||
user={mia}
|
||||
self={false}
|
||||
users={[mia]}
|
||||
quotaDefault={{ limit_usd: 10, period: "day" }}
|
||||
onClose={() => {}}
|
||||
onDone={onDone}
|
||||
/>
|
||||
);
|
||||
const select = screen.getByLabelText("Spend quota");
|
||||
expect(screen.getByRole("option", { name: "Default ($10.00 / day)" })).toBeInTheDocument();
|
||||
await user.selectOptions(select, "custom");
|
||||
await user.type(screen.getByLabelText("Limit in dollars"), "40");
|
||||
await user.type(screen.getByLabelText("Your password"), "pw");
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
await waitFor(() => expect(onDone).toHaveBeenCalled());
|
||||
expect(users.update).toHaveBeenCalledWith(
|
||||
7,
|
||||
{ quota: expect.objectContaining({ mode: "custom", limit_usd: 40, period: "day" }) },
|
||||
"pw"
|
||||
);
|
||||
});
|
||||
|
||||
it("No limit is a change; an empty custom limit keeps Save off", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<UserEditDialog user={mia} self={false} users={[mia]} onClose={() => {}} onDone={() => {}} />
|
||||
);
|
||||
await user.type(screen.getByLabelText("Your password"), "pw");
|
||||
await user.selectOptions(screen.getByLabelText("Spend quota"), "custom");
|
||||
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
|
||||
await user.selectOptions(screen.getByLabelText("Spend quota"), "unlimited");
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
await waitFor(() =>
|
||||
expect(users.update).toHaveBeenCalledWith(7, { quota: { mode: "unlimited" } }, "pw")
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -78,6 +78,13 @@
|
||||
"quotaWindow": {
|
||||
"wk": "Week",
|
||||
"mo": "Month"
|
||||
},
|
||||
"userQuota": "Quota {{pct}}%",
|
||||
"userQuotaTitle": "Your spend quota: {{spend}} of {{limit}} {{period}}",
|
||||
"userQuotaBlocked": "Your spend quota is used up ({{spend}} of {{limit}} {{period}}): no new session and no prompt until {{at}}",
|
||||
"userQuotaPeriod": {
|
||||
"day": "today",
|
||||
"5h": "in the last 5 hours"
|
||||
}
|
||||
},
|
||||
"version": "Version",
|
||||
|
||||
@@ -87,7 +87,8 @@
|
||||
"ELOCALMODE": "Accounts are managed in server mode only.",
|
||||
"ELASTADMIN": "The last admin cannot be deleted, demoted or disabled.",
|
||||
"EWEAKPASSWORD": "The new password does not meet every rule (or is the current one).",
|
||||
"EPWCHANGE": "Choose your own password first."
|
||||
"EPWCHANGE": "Choose your own password first.",
|
||||
"EBADQUOTA": "Enter a positive dollar limit and a period of a day or 5 hours."
|
||||
},
|
||||
"readOnly": "Analysts can view accounts but not change them.",
|
||||
"auditTitle": "Audit log",
|
||||
@@ -412,5 +413,37 @@
|
||||
"spendSub": "7 d {{week}} · 30 d {{month}}",
|
||||
"sessionsN": "{{n}} sessions",
|
||||
"workspacesN": "{{n}} workspaces",
|
||||
"tmuxCol": "tmux"
|
||||
"tmuxCol": "tmux",
|
||||
"quota": {
|
||||
"title": "Spend quota",
|
||||
"limit": "Limit in dollars",
|
||||
"period": "Period",
|
||||
"periods": {
|
||||
"day": "per day (from local midnight)",
|
||||
"5h": "per 5 hours (sliding)"
|
||||
},
|
||||
"per": {
|
||||
"day": "{{limit}} / day",
|
||||
"5h": "{{limit}} / 5 h"
|
||||
},
|
||||
"used": "{{spend}} of {{limit}}",
|
||||
"defaultTag": "(default)",
|
||||
"none": "No quota",
|
||||
"blockedUntil": "Used up — no new session and no prompt until {{at}}",
|
||||
"cardTitle": "Default spend quota",
|
||||
"cardDescription": "Every user without a quota of their own gets this one. Once it is used up they can start no session and their prompts are refused until it resets; a pane no hook can stop (Antigravity, Cursor, Codex without trusted hooks, omp on its own login) is ended.",
|
||||
"noDefault": "No default — no limit",
|
||||
"editDefault": "Edit",
|
||||
"defaultDialog": "Default spend quota",
|
||||
"defaultOn": "Limit what each user may spend",
|
||||
"defaultSaved": "Default spend quota saved",
|
||||
"enforceHint": "Over the quota: no new session, Claude Code and Codex prompts refused, other AI tools' panes ended — until it resets.",
|
||||
"modes": {
|
||||
"defaultOf": "Default ({{quota}})",
|
||||
"defaultNone": "Default (no limit)",
|
||||
"unlimited": "No limit",
|
||||
"custom": "Their own limit"
|
||||
},
|
||||
"now": "Now: {{spend}} of {{limit}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +78,13 @@
|
||||
"quotaWindow": {
|
||||
"wk": "Tuần",
|
||||
"mo": "Tháng"
|
||||
},
|
||||
"userQuota": "Quota {{pct}}%",
|
||||
"userQuotaTitle": "Quota chi phí của bạn: {{spend}} trên {{limit}} {{period}}",
|
||||
"userQuotaBlocked": "Bạn đã dùng hết quota chi phí ({{spend}} trên {{limit}} {{period}}): không mở phiên mới, không gửi prompt đến {{at}}",
|
||||
"userQuotaPeriod": {
|
||||
"day": "hôm nay",
|
||||
"5h": "trong 5 giờ qua"
|
||||
}
|
||||
},
|
||||
"version": "Phiên bản",
|
||||
|
||||
@@ -87,7 +87,8 @@
|
||||
"ELOCALMODE": "Chỉ quản lý tài khoản được ở chế độ server.",
|
||||
"ELASTADMIN": "Không thể xoá, hạ quyền hay khoá admin cuối cùng.",
|
||||
"EWEAKPASSWORD": "Mật khẩu mới chưa đạt đủ các yêu cầu (hoặc trùng mật khẩu hiện tại).",
|
||||
"EPWCHANGE": "Hãy đặt mật khẩu riêng của bạn trước."
|
||||
"EPWCHANGE": "Hãy đặt mật khẩu riêng của bạn trước.",
|
||||
"EBADQUOTA": "Nhập hạn mức USD lớn hơn 0 và chu kỳ ngày hoặc 5 giờ."
|
||||
},
|
||||
"readOnly": "Analyst chỉ xem được tài khoản, không thay đổi được.",
|
||||
"auditTitle": "Nhật ký audit",
|
||||
@@ -412,5 +413,37 @@
|
||||
"spendSub": "7 ngày {{week}} · 30 ngày {{month}}",
|
||||
"sessionsN": "{{n}} session",
|
||||
"workspacesN": "{{n}} workspace",
|
||||
"tmuxCol": "tmux"
|
||||
"tmuxCol": "tmux",
|
||||
"quota": {
|
||||
"title": "Quota chi phí",
|
||||
"limit": "Hạn mức (USD)",
|
||||
"period": "Chu kỳ",
|
||||
"periods": {
|
||||
"day": "mỗi ngày (từ nửa đêm giờ địa phương)",
|
||||
"5h": "mỗi 5 giờ (trượt)"
|
||||
},
|
||||
"per": {
|
||||
"day": "{{limit}} / ngày",
|
||||
"5h": "{{limit}} / 5 giờ"
|
||||
},
|
||||
"used": "{{spend}} trên {{limit}}",
|
||||
"defaultTag": "(mặc định)",
|
||||
"none": "Không giới hạn",
|
||||
"blockedUntil": "Đã hết — không mở phiên mới, không gửi prompt đến {{at}}",
|
||||
"cardTitle": "Quota chi phí mặc định",
|
||||
"cardDescription": "Áp cho mọi user không có quota riêng. Khi dùng hết, user không mở được phiên mới và prompt bị từ chối đến khi quota mở lại; pane không hook nào chặn được (Antigravity, Cursor, Codex chưa tin hook, omp dùng login riêng) sẽ bị dừng.",
|
||||
"noDefault": "Chưa có mặc định — không giới hạn",
|
||||
"editDefault": "Sửa",
|
||||
"defaultDialog": "Quota chi phí mặc định",
|
||||
"defaultOn": "Giới hạn chi phí của mỗi user",
|
||||
"defaultSaved": "Đã lưu quota chi phí mặc định",
|
||||
"enforceHint": "Vượt quota: không mở phiên mới, prompt Claude Code và Codex bị từ chối, pane của các AI tool khác bị dừng — đến khi quota mở lại.",
|
||||
"modes": {
|
||||
"defaultOf": "Mặc định ({{quota}})",
|
||||
"defaultNone": "Mặc định (không giới hạn)",
|
||||
"unlimited": "Không giới hạn",
|
||||
"custom": "Hạn mức riêng"
|
||||
},
|
||||
"now": "Hiện tại: {{spend}} trên {{limit}}"
|
||||
}
|
||||
}
|
||||
|
||||
+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()}`),
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
* an admin — add a user, and ONE Edit per row (`UserEditDialog`: profile,
|
||||
* role, active/disabled, password, owned workspaces, sign out everywhere, sent
|
||||
* as one change), and the Claude/Codex account pool every user's sessions run
|
||||
* on (`AccountPoolCard`).
|
||||
* on (`AccountPoolCard`). Each row's spend shows its spend quota (spent of the
|
||||
* limit, red once used up) and the default quota sits in its own card
|
||||
* (`UserQuota.tsx`); a user's own quota is part of Edit.
|
||||
* Every change asks for the admin's OWN password again (the
|
||||
* server refuses without it); an analyst only reads. A row shows the display
|
||||
* name and email under the username when set, and "New password due" while
|
||||
@@ -61,6 +63,7 @@ import {
|
||||
type AuthUser,
|
||||
type MemberTmuxRow,
|
||||
type ServerUserUsage,
|
||||
type UserQuota,
|
||||
type UserStorage,
|
||||
} from "../lib/api";
|
||||
import { fmt, fmtCost, formatBytes, formatDateTime } from "../lib/format";
|
||||
@@ -74,6 +77,7 @@ import { ROLE_BADGE, UserEditDialog } from "../components/users/UserEditDialog";
|
||||
import { AccountPoolCard } from "../components/users/AccountPoolCard";
|
||||
import { UserDetail } from "../components/users/UserDetail";
|
||||
import { UserAvatar } from "../components/users/UserAvatar";
|
||||
import { QuotaDefaultCard, QuotaSummary } from "../components/users/UserQuota";
|
||||
import {
|
||||
MemberTmuxBar,
|
||||
TmuxCell,
|
||||
@@ -104,6 +108,8 @@ export function Users({
|
||||
const isAdmin = me?.role === "admin";
|
||||
|
||||
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
|
||||
// The default spend quota (`undefined` until loaded or from an older server).
|
||||
const [quotaDefault, setQuotaDefault] = useState<UserQuota | null | undefined>(undefined);
|
||||
const [audit, setAudit] = useState<AuditEntry[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
@@ -151,6 +157,7 @@ export function Users({
|
||||
.list()
|
||||
.then((r) => {
|
||||
setUsers(r.users);
|
||||
setQuotaDefault(r.quotaDefault);
|
||||
setError(null);
|
||||
})
|
||||
.catch((e: Error) => setError(e.message)),
|
||||
@@ -406,6 +413,7 @@ export function Users({
|
||||
month: fmtCost(u.last30d.cost),
|
||||
})}
|
||||
</div>
|
||||
<QuotaSummary quota={u.quota} />
|
||||
</td>
|
||||
<StorageCell storage={home} measuring={measuring} disk={homesDisk} />
|
||||
{showTmux && (
|
||||
@@ -487,6 +495,17 @@ export function Users({
|
||||
);
|
||||
})()}
|
||||
|
||||
{quotaDefault !== undefined && (
|
||||
<QuotaDefaultCard
|
||||
quotaDefault={quotaDefault}
|
||||
isAdmin={isAdmin}
|
||||
onSaved={(message) => {
|
||||
toast.success(message);
|
||||
void load();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AccountPoolCard isAdmin={isAdmin} />
|
||||
|
||||
{children}
|
||||
@@ -559,6 +578,7 @@ export function Users({
|
||||
{editing && (
|
||||
<UserEditDialog
|
||||
user={editing}
|
||||
quotaDefault={quotaDefault ?? null}
|
||||
self={editing.id === me.id}
|
||||
users={users ?? []}
|
||||
onClose={() => setEditing(null)}
|
||||
|
||||
+2
-1
@@ -89,7 +89,7 @@ A dashboard shared by several people runs in **server mode** ([SERVER-MODE.md](S
|
||||
| `POST /api/auth/login` `{username, password}` | `{user}` + the cookie; `401 EBADLOGIN`, `429 ETOOMANY`, `404 ENOTSERVER` in local mode |
|
||||
| `POST /api/auth/logout` | `{ok: true}`, cookie cleared |
|
||||
|
||||
In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` — the last login ever, kept after it ends —, `mustChangePassword` included) with its workspaces, sessions and `today` (since the viewer's local midnight — `?tz_offset=` is the browser's `getTimezoneOffset()`, UTC without it) / 7 / 30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {<id>: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), `GET /api/users/tmux` each member's own tmux server under one Linux user per account (`{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]}`; an admin's `POST /api/users/:id/tmux-restart` and `POST /api/users/tmux-restart-idle` restart them with `currentPassword`, audited, `409 ETMUXSTUCK` for a server that will not stop), `GET /api/host` the machine and each user's live share of it (`{at, hostname, os, uptime, cpu: {model, cores, load}, memory: {total, available, used, swapTotal, swapUsed}, disks: [{device, mount, type, kind: "HDD"|"SSD"|null, total, used, free}], homesMount, gpus: [{name, utilization, memoryUsed, memoryTotal}], usage: {cpu, sampleMs, groups: [{key, kind: "user"|"host"|"system", userId?, username?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null, bootTime, network: [{name, rx, tx}], diskIo: [{name, read, write}], temperatures: [{chip, label, celsius}], pressure: {cpu, memory, io: {some10, some60, full10} | null} | null (Linux PSI, `/proc/pressure/*`: % of the last 10 / 60 s tasks stalled on it), topProcesses: [{pid, name, cpu, memory, group}], process: {pid, node, uptime, rss, heapUsed}}`, `cpu` also carrying `physicalCores` and `perCore` (busy % per thread), rates in bytes/s over the sample — read-only, cached 3 s, `usage` null and the lists empty off Linux), `GET /api/host/history?range=1h|6h|24h` the CPU/memory history every server on Linux keeps in memory since it started (both read by the Dashboard's System Health tab and open to every signed-in user — `/host` is not staff-only) (`{range, intervalMs, running, startedAt, points: [{at, cpu, memory, memoryUsed, swapUsed, load1, users: {<id>: {cpu, memory}}}]}`, one point per 30 s, averaged to ≤ 360), `GET /api/users/:id/details[?tz_offset=]` one account in full for the Administration page's user detail (`{user (with logins, lastLoginAt), sessions: {total, active, firstAt, lastAt}, usage: {today, last7d, last30d, daily: [{date, cost, tokens}] (30 local days, oldest first), byTool, byModel: [{key, cost, tokens, sessions}]}, workspaces: [{id, title, cwd, tool, kind, branch, status, createdAt, sessions, lastActiveAt, livePanes}], panes: [{id, laneId, laneTitle, laneCwd, tool, model, account, proxy, sessionId, startedAt, status}], recentSessions (15, with cost/tokens), pool: {claude|codex|cursor: {pin, group, accounts: [{name, kind, email, expired, allowed, pinned, quota, quotaPercent, full, livePanes, spend5h, spend7d, userSpend5h, userSpend7d, userTokens5h, userTokens7d, userRuns7d, userLivePanes, userLastAt}]}}, audit}` — `user*` fields are only the runs on that user's workspaces; never a token; `404 ENOUSER`), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (kept `AGENTHUB_AUDIT_DAYS` days, default 180, `0` = forever) (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
|
||||
In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` — the last login ever, kept after it ends —, `mustChangePassword` included) with its workspaces, sessions and `today` (since the viewer's local midnight — `?tz_offset=` is the browser's `getTimezoneOffset()`, UTC without it) / 7 / 30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {<id>: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), `GET /api/users/tmux` each member's own tmux server under one Linux user per account (`{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]}`; an admin's `POST /api/users/:id/tmux-restart` and `POST /api/users/tmux-restart-idle` restart them with `currentPassword`, audited, `409 ETMUXSTUCK` for a server that will not stop), `GET /api/host` the machine and each user's live share of it (`{at, hostname, os, uptime, cpu: {model, cores, load}, memory: {total, available, used, swapTotal, swapUsed}, disks: [{device, mount, type, kind: "HDD"|"SSD"|null, total, used, free}], homesMount, gpus: [{name, utilization, memoryUsed, memoryTotal}], usage: {cpu, sampleMs, groups: [{key, kind: "user"|"host"|"system", userId?, username?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null, bootTime, network: [{name, rx, tx}], diskIo: [{name, read, write}], temperatures: [{chip, label, celsius}], pressure: {cpu, memory, io: {some10, some60, full10} | null} | null (Linux PSI, `/proc/pressure/*`: % of the last 10 / 60 s tasks stalled on it), topProcesses: [{pid, name, cpu, memory, group}], process: {pid, node, uptime, rss, heapUsed}}`, `cpu` also carrying `physicalCores` and `perCore` (busy % per thread), rates in bytes/s over the sample — read-only, cached 3 s, `usage` null and the lists empty off Linux), `GET /api/host/history?range=1h|6h|24h` the CPU/memory history every server on Linux keeps in memory since it started (both read by the Dashboard's System Health tab and open to every signed-in user — `/host` is not staff-only) (`{range, intervalMs, running, startedAt, points: [{at, cpu, memory, memoryUsed, swapUsed, load1, users: {<id>: {cpu, memory}}}]}`, one point per 30 s, averaged to ≤ 360), `GET /api/users/:id/details[?tz_offset=]` one account in full for the Administration page's user detail (`{user (with logins, lastLoginAt), sessions: {total, active, firstAt, lastAt}, usage: {today, last7d, last30d, daily: [{date, cost, tokens}] (30 local days, oldest first), byTool, byModel: [{key, cost, tokens, sessions}]}, workspaces: [{id, title, cwd, tool, kind, branch, status, createdAt, sessions, lastActiveAt, livePanes}], panes: [{id, laneId, laneTitle, laneCwd, tool, model, account, proxy, sessionId, startedAt, status}], recentSessions (15, with cost/tokens), pool: {claude|codex|cursor: {pin, group, accounts: [{name, kind, email, expired, allowed, pinned, quota, quotaPercent, full, livePanes, spend5h, spend7d, userSpend5h, userSpend7d, userTokens5h, userTokens7d, userRuns7d, userLivePanes, userLastAt}]}}, audit}` — `user*` fields are only the runs on that user's workspaces; never a token; `404 ENOUSER`), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (kept `AGENTHUB_AUDIT_DAYS` days, default 180, `0` = forever) (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). `GET /api/users` also carries each account's [spend quota](SERVER-MODE.md#spend-quotas) — `quotaSetting` (`{mode: "default"|"unlimited"|"custom", limit_usd?, period?, tz_offset?}`) and `quota` (the one binding them now with `spend`, `pct`, `exceeded`, `resetsAt`, or null) — and the top-level `quotaDefault`; an admin sets the default with `PUT /api/users/quota-default` `{quota: {limit_usd, period, tz_offset} | null, currentPassword}` and a user's own with `PATCH /api/users/:id` `{quota}` (`400 EBADQUOTA`). A workspace owner over their quota gets `429 EQUOTA` from `POST /api/run` and `POST /api/lanes/:id/start`. See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
|
||||
|
||||
An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`; the password may be any non-empty starting one and the account gets `mustChangePassword: true`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `GET /api/users/:id/avatar` (that account's picture for the sidebar's owner groups — admin or analyst, `nosniff` + `sandbox` CSP, `404 ENOAVATAR`; a workspace in `GET /api/lanes` carries `owner_avatar_at` to version its URL), `PATCH /api/users/:id` (any of `{displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true}` — every field validated before any is written; `removeWorkspaces` leaves the workspace with no owner and answers `409 ENOTOWNED` for one the user does not own; an admin's own `password`/`signOut` keeps the login it came from; a password for SOMEONE ELSE may be anything non-empty and sets their `mustChangePassword`, the admin's OWN must pass every rule below, `400 EWEAKPASSWORD` with `rules`), `POST /api/users/:id/password` (`{password}`; that user's logins end; the same two bars) `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user) and `DELETE /api/users/:id` (delete for good: the user's logins, panes, workspaces' records, prefs and — under S3 — their Linux account and whole home, removed FIRST so a refusal (`502 EPROVISION`) leaves every row; `409 ESELF` for yourself, `409 ELASTADMIN` for the last admin; session history and the audit log stay). No path — this API or the `agenthub users` CLI — may demote, disable or delete the last enabled admin (`409 ELASTADMIN`). Every one carries `currentPassword`, the admin's own password: `403 EREAUTH` when it is wrong or missing, `429 ETOOMANY` after 10 wrong tries in 15 minutes. `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`/`EBADEMAIL`/`EBADFIELD`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log.
|
||||
|
||||
@@ -105,6 +105,7 @@ An admin (server mode) adds an account with `POST /api/accounts` `{tool: "claude
|
||||
| `PUT /api/me/prefs` `{prefs: {<key>: "<value>" \| null}}` | merged (`null` deletes a key), the same shape back; `400 EBADPREFS` for a key outside the list below or a value that is not a string of at most 8 KB |
|
||||
| `GET /api/me/instructions` | `{instructions, max, tools}` — `tools` lists the AI tool ids whose new panes receive them (`["claude","codex"]`; Cursor CLI and Antigravity have no per-session way to take them). The account's own agent instructions (Settings → AI tools → My agent instructions, `""` when none); `401 EAUTH` with no login |
|
||||
| `PUT /api/me/instructions` `{instructions: "<markdown>" \| null}` | replaced, the same shape back; blank or `null` clears; `400 EBADFIELD` not a string, `413 ETOOBIG` over 16 000 characters. Put BEFORE the workspace's own instructions in every new Claude/Codex pane of a workspace the account owns (local mode: every workspace) — see Workspace context below |
|
||||
| `GET /api/me/quota` | server mode: `{quota: {limit_usd, period: "day"\|"5h", tz_offset, source: "default"\|"user", spend, pct, exceeded, resetsAt, since} \| null}` — the account's own [spend quota](SERVER-MODE.md#spend-quotas) now; `null` without one, and always in local mode |
|
||||
| `GET /api/me/pool-accounts` | server mode: `{tools: [{tool, pin, choice, accounts: [{name, kind, email, expired, quotaPercent, full}]}]}` — per tool (`claude`, `codex`, `cursor`) with at least one account the account's [pool group](SERVER-MODE.md#a-users-own-pool-account) allows; never a token. Local mode: `{tools: []}` |
|
||||
| `PUT /api/me/pool-accounts` `{tool, account: "<name>" \| null}` | the account's new sessions of `tool` run on `account` (`null` = automatic): `{tool, choice, switched}` — `switched` as for an admin's pin (a Claude login goes into the home at once). Below an admin's pin (`409 ELOCKED`), only an account the group allows (`404 ENOACCOUNT`), `400 EBADTOOL`; login cookie only, audited |
|
||||
| `POST /api/me/onboarded` | the first-run setup is done or skipped (kept from the first call) |
|
||||
|
||||
@@ -710,6 +710,55 @@ omp login # e.g. anthropic
|
||||
> runs; it cannot carry a login away. A member who edits the `agenthub`
|
||||
> provider out of their `models.yml` gets it back at their next omp pane.
|
||||
|
||||
## Spend quotas
|
||||
|
||||
An admin limits what each user may spend, in dollars, on the Users page
|
||||
(`server/lib/user-quota.js`):
|
||||
|
||||
- **Default spend quota** card: one limit every user without their own gets
|
||||
(`PUT /api/users/quota-default` `{quota: {limit_usd, period, tz_offset} | null}`).
|
||||
- **Edit user → Spend quota**: *Default*, *No limit*, or *Their own limit*
|
||||
(`PATCH /api/users/:id` `{quota: {mode: "default" | "unlimited" | "custom", …}}`).
|
||||
- A period is `day` (from local midnight of the zone the admin's browser was
|
||||
in when they saved it) or `5h` (sliding: the current UTC hour and the four
|
||||
before it — spend booked in hour H counts until H + 5 h).
|
||||
- Both writes need the admin's own password again and are audited, like
|
||||
every account change; each one re-applies at once (`sweep`).
|
||||
|
||||
Spend is the users page's own figure: the tokens of the sessions the user
|
||||
OWNS through a workspace, priced by `calculateCost`. The Users table shows
|
||||
each user's spend against their quota; the user sees their own as **Quota N%**
|
||||
in the status bar (`GET /api/me/quota`).
|
||||
|
||||
Once a user's spend reaches the limit, until the window opens again:
|
||||
|
||||
- **No new session.** Every start, resume or prompt from the workspace home
|
||||
goes through `spawnRun`, which answers `429 EQUOTA` with the reason.
|
||||
- **Claude Code and Codex prompts are refused** by the hooks. Every pane
|
||||
carries `AGENTHUB_QUOTA_FILE`. The dashboard writes that file every minute:
|
||||
the host's under `<data dir>/user-quota/u<id>.json`, a member's (S3) in
|
||||
their handoff dir through `write-as`. While it says `blocked`,
|
||||
`scripts/hook-handler.js` exits 2 on `UserPromptSubmit` with the reason on
|
||||
stderr, and the CLI drops the prompt and shows why. The hook reads only that
|
||||
local file and never asks the dashboard. It fails open: a missing or
|
||||
unreadable file, or a passed `until`, lets the prompt through. Codex takes
|
||||
this only with AgentHub's Codex hooks installed and trusted.
|
||||
- **A member's omp on the shared login** (above) loses its gateway key: the
|
||||
run's key is left out of `clients.json` until the quota opens. Its pane stays.
|
||||
- **Every other pane of theirs is ended.** This covers Antigravity, Cursor, a
|
||||
Codex without trusted hooks (a member's Codex always), an omp on its own
|
||||
login, and any other catalog tool. Plain shells are left alone.
|
||||
|
||||
Limits of the check:
|
||||
|
||||
- Tokens land after a turn, so a user can finish one turn past the limit
|
||||
(omp/Codex journals are polled, so a little more).
|
||||
- A user's own terminal outside AgentHub carries no flag file.
|
||||
- Without S3 every pane runs as the host account, which can edit the flag.
|
||||
|
||||
Turn the minute sweep off with `DASHBOARD_QUOTA_SWEEP_MS=0`. The `EQUOTA`
|
||||
refusal at start does not depend on it.
|
||||
|
||||
## One Linux user per account
|
||||
|
||||
Every pane of a user, an agent's shell included, runs as that user's own Linux
|
||||
|
||||
+177
-2
@@ -3,7 +3,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Agent Dashboard for Claude Code API
|
||||
version: 2.8.29
|
||||
version: 2.8.30
|
||||
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
||||
contact:
|
||||
name: Nguyễn Ngọc Trí Vĩ
|
||||
@@ -4657,6 +4657,73 @@ components:
|
||||
description: Events attributed to this run's inner agents, ordered by created_at then id. Capped at 5000 rows.
|
||||
items:
|
||||
$ref: '#/components/schemas/DashboardEvent'
|
||||
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:
|
||||
@@ -10975,6 +11042,12 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'429':
|
||||
description: 'EQUOTA (server mode): the workspace owner''s spend quota is used up until it resets.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'500':
|
||||
description: Unexpected failure (EINTERNAL).
|
||||
content:
|
||||
@@ -14453,6 +14526,30 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/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':
|
||||
description: EAUTH
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/me/instructions:
|
||||
get:
|
||||
tags:
|
||||
@@ -14966,6 +15063,16 @@ paths:
|
||||
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
|
||||
'403':
|
||||
description: EADMIN — a developer
|
||||
content:
|
||||
@@ -15303,6 +15410,8 @@ paths:
|
||||
type: boolean
|
||||
enum:
|
||||
- true
|
||||
quota:
|
||||
$ref: '#/components/schemas/UserQuotaSetting'
|
||||
currentPassword:
|
||||
type: string
|
||||
responses:
|
||||
@@ -15316,7 +15425,7 @@ paths:
|
||||
user:
|
||||
$ref: '#/components/schemas/AuthUser'
|
||||
'400':
|
||||
description: EBADPASSWORD / EBADROLE / EBADFIELD / EBADEMAIL
|
||||
description: EBADPASSWORD / EBADROLE / EBADFIELD / EBADEMAIL / EBADQUOTA
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -15413,6 +15522,72 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/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:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- quota
|
||||
- currentPassword
|
||||
properties:
|
||||
quota:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/UserQuota'
|
||||
nullable: true
|
||||
currentPassword:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Saved
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
user:
|
||||
$ref: '#/components/schemas/AuthUser'
|
||||
quotaDefault:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/UserQuota'
|
||||
nullable: true
|
||||
'400':
|
||||
description: EBADQUOTA
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'403':
|
||||
description: EREAUTH (own password wrong) / EADMIN / ELOCALMODE
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'404':
|
||||
description: ENOUSER / ENOLANE
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'409':
|
||||
description: ESELF — your own role or disable
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'429':
|
||||
description: ETOOMANY — 10 wrong passwords in 15 minutes
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/users/{id}/password:
|
||||
post:
|
||||
tags:
|
||||
|
||||
@@ -16,9 +16,18 @@
|
||||
* for the response is what made Claude Code sit at "running hooks" for seconds
|
||||
* whenever a dashboard was busy, slow, or wedged.
|
||||
*
|
||||
* The one thing it may refuse: in server mode a pane carries
|
||||
* `AGENTHUB_QUOTA_FILE` (server/lib/user-quota.js), and while that file says
|
||||
* its owner is over their spend quota a Claude Code or Codex
|
||||
* `UserPromptSubmit` exits 2 with the reason on stderr — both CLIs then drop
|
||||
* the prompt and show why. Decided from that local file alone, never by
|
||||
* asking the dashboard, and fail-open: no file, an unreadable one or an
|
||||
* expired `until` lets the prompt through. Antigravity never gets a refusal.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const http = require("http");
|
||||
|
||||
const hookType = process.argv[2] || "unknown";
|
||||
@@ -44,11 +53,36 @@ function resolvePorts() {
|
||||
|
||||
const ports = resolvePorts();
|
||||
|
||||
/** The reason to refuse this prompt (spend quota used up), or null to let it through. */
|
||||
function quotaRefusal() {
|
||||
const file = process.env.AGENTHUB_QUOTA_FILE;
|
||||
if (!file || hookType !== "UserPromptSubmit" || tool === "antigravity") return null;
|
||||
try {
|
||||
if (fs.statSync(file).size > 64 * 1024) return null;
|
||||
const flag = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
if (flag?.blocked !== true) return null;
|
||||
const until = Date.parse(flag.until);
|
||||
if (Number.isFinite(until) && until <= Date.now()) return null;
|
||||
return typeof flag.message === "string" && flag.message
|
||||
? flag.message
|
||||
: "AgentHub: your spend quota is used up.";
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const refusal = quotaRefusal();
|
||||
|
||||
let input = "";
|
||||
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.on("data", (chunk) => (input += chunk));
|
||||
process.stdin.on("end", () => {
|
||||
if (refusal) {
|
||||
// The prompt never runs, so it is not reported either.
|
||||
process.stderr.write(`${refusal}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
let parsedData;
|
||||
try {
|
||||
parsedData = JSON.parse(input);
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
/**
|
||||
* @file user-quota.test.js
|
||||
* @description Spend quotas per user (server/lib/user-quota.js): the default
|
||||
* and a user's own setting, spend over a day and a sliding 5 h window, the
|
||||
* admin routes (password again), `/api/me/quota`, the EQUOTA refusal of a new
|
||||
* pane, the sweep's flag files and the panes it ends, the omp gateway keys it
|
||||
* leaves out, and the hook handler refusing a prompt from the flag alone.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const { spawnSync } = require("node:child_process");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "user-quota-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
|
||||
process.env.CLAUDE_HOME = path.join(TMP, ".claude");
|
||||
process.env.CODEX_HOME = path.join(TMP, ".codex");
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
process.env.DASHBOARD_QUOTA_SWEEP_MS = "0";
|
||||
|
||||
const { createApp, startServer } = require("../index");
|
||||
const { db } = require("../db");
|
||||
const auth = require("../lib/auth");
|
||||
const quota = require("../lib/user-quota");
|
||||
const runs = require("../lib/pty-run");
|
||||
|
||||
let server;
|
||||
let BASE;
|
||||
const BOSS_PASS = "admin-pass-1";
|
||||
const ids = {};
|
||||
let laneId;
|
||||
|
||||
async function call(method, p, { cookie, body } = {}) {
|
||||
const res = await fetch(BASE + p, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json", ...(cookie ? { cookie } : {}) },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return { status: res.status, body: await res.json().catch(() => null) };
|
||||
}
|
||||
|
||||
async function login(username, password) {
|
||||
const res = await fetch(BASE + "/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
return res.headers.get("set-cookie").split(";")[0];
|
||||
}
|
||||
|
||||
const HOUR = 3_600_000;
|
||||
const hourOf = (ms) => new Date(ms).toISOString().slice(0, 13);
|
||||
|
||||
/** `tokens` output tokens of Sonnet 4.5 ($15 / M) booked to `sid` in the hour of `at`. */
|
||||
function spend(sid, tokens, at = Date.now()) {
|
||||
db.prepare(
|
||||
`INSERT INTO token_usage (session_id, model, output_tokens)
|
||||
VALUES (?, 'claude-sonnet-4-5-20250929', ?)
|
||||
ON CONFLICT DO UPDATE SET output_tokens = output_tokens + excluded.output_tokens`
|
||||
).run(sid, tokens);
|
||||
db.prepare(
|
||||
`INSERT INTO token_usage_hourly (session_id, hour, model, output_tokens)
|
||||
VALUES (?, ?, 'claude-sonnet-4-5-20250929', ?)`
|
||||
).run(sid, hourOf(at), tokens);
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
server = await startServer(createApp(), 0);
|
||||
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
ids.boss = (await auth.createUser("boss", BOSS_PASS, "admin")).id;
|
||||
ids.mia = (await auth.createUser("mia", "member-pass-1", "developer")).id;
|
||||
ids.lou = (await auth.createUser("lou", "member-pass-2", "developer")).id;
|
||||
laneId = Number(
|
||||
db
|
||||
.prepare(
|
||||
"INSERT INTO lanes (cwd, title, tool, owner_id) VALUES ('/w/mia', 'Mia', 'claude', ?)"
|
||||
)
|
||||
.run(ids.mia).lastInsertRowid
|
||||
);
|
||||
db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, cwd, lane_id, updated_at) VALUES ('sm', 'mia work', 'active', '/w/mia', ?, ?)"
|
||||
).run(laneId, new Date().toISOString());
|
||||
spend("sm", 1_000_000); // $15 now
|
||||
});
|
||||
|
||||
after(() => {
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
server?.close();
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("settings", () => {
|
||||
it("a user without a row of their own gets the default; unlimited and custom override it", () => {
|
||||
assert.equal(quota.effective(ids.mia), null);
|
||||
quota.setDefault({ limit_usd: 10, period: "day", tz_offset: -420 });
|
||||
assert.deepEqual(quota.effective(ids.mia), {
|
||||
source: "default",
|
||||
limit_usd: 10,
|
||||
period: "day",
|
||||
tz_offset: -420,
|
||||
});
|
||||
quota.setUserQuota(ids.mia, { mode: "unlimited" });
|
||||
assert.equal(quota.effective(ids.mia), null);
|
||||
assert.deepEqual(quota.getOverride(ids.mia), { mode: "unlimited" });
|
||||
quota.setUserQuota(ids.mia, { mode: "custom", limit_usd: 50, period: "5h" });
|
||||
assert.equal(quota.effective(ids.mia).source, "user");
|
||||
assert.equal(quota.effective(ids.mia).limit_usd, 50);
|
||||
quota.setUserQuota(ids.mia, { mode: "default" });
|
||||
assert.equal(quota.effective(ids.mia).source, "default");
|
||||
quota.setDefault(null);
|
||||
assert.equal(quota.effective(ids.mia), null);
|
||||
});
|
||||
|
||||
it("refuses a bad limit, period or mode", () => {
|
||||
for (const bad of [
|
||||
{ mode: "custom", limit_usd: 0, period: "day" },
|
||||
{ mode: "custom", limit_usd: 5, period: "week" },
|
||||
{ mode: "custom", limit_usd: 5, period: "day", tz_offset: 9999 },
|
||||
{ mode: "nope" },
|
||||
]) {
|
||||
assert.throws(() => quota.setUserQuota(ids.mia, bad), { code: "EBADQUOTA" });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("spend windows", () => {
|
||||
it("a day starts at the zone's local midnight and resets at the next one", () => {
|
||||
const now = Date.parse("2026-10-28T03:30:00Z"); // 10:30 at UTC+7
|
||||
const w = quota.windowOf({ period: "day", tz_offset: -420 }, now);
|
||||
assert.equal(w.since, "2026-10-27T17");
|
||||
assert.equal(new Date(w.nextReset).toISOString(), "2026-10-28T17:00:00.000Z");
|
||||
});
|
||||
|
||||
it("5 h is the current hour and the four before it; it opens as the oldest hours leave", () => {
|
||||
const now = Date.parse("2026-10-28T10:20:00Z");
|
||||
assert.equal(quota.windowOf({ period: "5h" }, now).since, "2026-10-28T06");
|
||||
const byHour = new Map([
|
||||
["2026-10-28T06", 4],
|
||||
["2026-10-28T08", 3],
|
||||
["2026-10-28T10", 2],
|
||||
]);
|
||||
// 9 spent, limit 6: 06's $4 leaving brings it to 5 < 6 → at 06 + 5 h.
|
||||
assert.equal(
|
||||
new Date(quota.slidingReset(byHour, 9, 6)).toISOString(),
|
||||
"2026-10-28T11:00:00.000Z"
|
||||
);
|
||||
// limit 3: everything up to 08 must go → 08 + 5 h.
|
||||
assert.equal(
|
||||
new Date(quota.slidingReset(byHour, 9, 3)).toISOString(),
|
||||
"2026-10-28T13:00:00.000Z"
|
||||
);
|
||||
});
|
||||
|
||||
it("counts the spend of the sessions a user owns, inside the window only", () => {
|
||||
db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, cwd, lane_id, updated_at) VALUES ('old', 'old', 'completed', '/w/mia', ?, ?)"
|
||||
).run(laneId, new Date().toISOString());
|
||||
spend("old", 1_000_000, Date.now() - 8 * HOUR); // outside 5 h
|
||||
quota.setUserQuota(ids.mia, { mode: "custom", limit_usd: 100, period: "5h" });
|
||||
const s = quota.statusFor(ids.mia);
|
||||
assert.equal(s.spend, 15);
|
||||
assert.equal(s.exceeded, false);
|
||||
assert.equal(s.resetsAt, null);
|
||||
assert.equal(quota.statusFor(ids.lou), null);
|
||||
quota.setUserQuota(ids.mia, { mode: "default" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("routes", () => {
|
||||
it("an admin sets the default with their password; the list shows each user's quota", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
const noPass = await call("PUT", "/api/users/quota-default", {
|
||||
cookie: boss,
|
||||
body: { quota: { limit_usd: 10, period: "day" } },
|
||||
});
|
||||
assert.equal(noPass.status, 403);
|
||||
const bad = await call("PUT", "/api/users/quota-default", {
|
||||
cookie: boss,
|
||||
body: { quota: { limit_usd: -1, period: "day" }, currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(bad.status, 400);
|
||||
assert.equal(bad.body.error.code, "EBADQUOTA");
|
||||
const ok = await call("PUT", "/api/users/quota-default", {
|
||||
cookie: boss,
|
||||
body: { quota: { limit_usd: 10, period: "day" }, currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(ok.status, 200, JSON.stringify(ok.body));
|
||||
assert.equal(ok.body.quotaDefault.limit_usd, 10);
|
||||
|
||||
const list = await call("GET", "/api/users", { cookie: boss });
|
||||
assert.equal(list.body.quotaDefault.limit_usd, 10);
|
||||
const mia = list.body.users.find((u) => u.id === ids.mia);
|
||||
assert.deepEqual(mia.quotaSetting, { mode: "default" });
|
||||
assert.equal(mia.quota.spend, 15);
|
||||
assert.equal(mia.quota.exceeded, true);
|
||||
assert.ok(mia.quota.resetsAt);
|
||||
});
|
||||
|
||||
it("a developer reads their own quota and may not change any", async () => {
|
||||
const me = await login("mia", "member-pass-1");
|
||||
const r = await call("GET", "/api/me/quota", { cookie: me });
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.body.quota.exceeded, true);
|
||||
const put = await call("PUT", "/api/users/quota-default", {
|
||||
cookie: me,
|
||||
body: { quota: null, currentPassword: "member-pass-1" },
|
||||
});
|
||||
assert.equal(put.status, 403);
|
||||
});
|
||||
|
||||
it("PATCH /api/users/:id {quota} overrides one user's, audited", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
const r = await call("PATCH", `/api/users/${ids.mia}`, {
|
||||
cookie: boss,
|
||||
body: { quota: { mode: "custom", limit_usd: 40, period: "day" }, currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(r.status, 200, JSON.stringify(r.body));
|
||||
assert.equal(quota.statusFor(ids.mia).exceeded, false);
|
||||
const audit = db
|
||||
.prepare("SELECT path FROM audit_log WHERE owner_id = ? ORDER BY id DESC LIMIT 1")
|
||||
.get(ids.mia);
|
||||
assert.match(audit.path, /spend quota \$40\/day/);
|
||||
const bad = await call("PATCH", `/api/users/${ids.mia}`, {
|
||||
cookie: boss,
|
||||
body: { quota: { mode: "lots" }, currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(bad.status, 400);
|
||||
quota.setUserQuota(ids.mia, { mode: "default" }); // back to the $10 default: over
|
||||
});
|
||||
});
|
||||
|
||||
describe("enforcement", () => {
|
||||
it("refuses a new pane of an owner over quota with EQUOTA and a reason", () => {
|
||||
assert.throws(
|
||||
() => quota.assertSpawnAllowed(ids.mia),
|
||||
(err) => {
|
||||
assert.equal(err.code, "EQUOTA");
|
||||
assert.match(err.message, /\$15\.00 of \$10\.00 per day/);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
quota.assertSpawnAllowed(ids.lou); // under the default: no throw
|
||||
quota.assertSpawnAllowed(null); // a workspace with no owner
|
||||
});
|
||||
|
||||
it("gives a pane its owner's flag file in server mode only", () => {
|
||||
const env = quota.flagEnv(ids.mia, null);
|
||||
assert.match(env.AGENTHUB_QUOTA_FILE, /user-quota[/\\]u\d+\.json$/);
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
try {
|
||||
assert.deepEqual(quota.flagEnv(ids.mia, null), {});
|
||||
quota.assertSpawnAllowed(ids.mia); // local mode never refuses
|
||||
assert.equal(quota.statusAll().size, 0);
|
||||
} finally {
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
}
|
||||
});
|
||||
|
||||
it("sweep: writes the flag, ends the panes no hook can stop, keeps Claude's, clears on raise", () => {
|
||||
const realList = runs.listRuns;
|
||||
const realKill = runs.killRun;
|
||||
const killed = [];
|
||||
runs.listRuns = () => [
|
||||
{ id: `agenthub-lane-${laneId}`, laneId, status: "running", tool: "claude" },
|
||||
{ id: `agenthub-lane-${laneId}-1`, laneId, status: "running", tool: "cursor" },
|
||||
];
|
||||
runs.killRun = (id) => killed.push(id) > 0;
|
||||
try {
|
||||
quota.__reset();
|
||||
const r = quota.sweep();
|
||||
assert.deepEqual(r.blocked, [ids.mia]);
|
||||
assert.deepEqual(killed, [`agenthub-lane-${laneId}-1`]);
|
||||
const file = quota.flagEnv(ids.mia, null).AGENTHUB_QUOTA_FILE;
|
||||
const flag = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
assert.equal(flag.blocked, true);
|
||||
assert.match(flag.message, /quota is used up/);
|
||||
assert.ok(Date.parse(flag.until) > Date.now());
|
||||
// Lou never went over: no file is written for them.
|
||||
assert.equal(fs.existsSync(quota.flagEnv(ids.lou, null).AGENTHUB_QUOTA_FILE), false);
|
||||
assert.equal(quota.isRunBlocked(`agenthub-lane-${laneId}-3`), true);
|
||||
|
||||
quota.setUserQuota(ids.mia, { mode: "unlimited" });
|
||||
killed.length = 0;
|
||||
quota.sweep();
|
||||
assert.deepEqual(killed, []);
|
||||
assert.equal(JSON.parse(fs.readFileSync(file, "utf8")).blocked, false);
|
||||
assert.equal(quota.isRunBlocked(`agenthub-lane-${laneId}`), false);
|
||||
} finally {
|
||||
runs.listRuns = realList;
|
||||
runs.killRun = realKill;
|
||||
quota.setUserQuota(ids.mia, { mode: "default" });
|
||||
quota.__reset();
|
||||
}
|
||||
});
|
||||
|
||||
it("the omp gateway loses a blocked owner's key, never the queue's", () => {
|
||||
const { clientsFor } = require("../lib/omp-share");
|
||||
const t = { standby: ["s1"], used: { a: { hash: "ha" }, b: { hash: "hb" } } };
|
||||
const out = clientsFor(t, (id) => id === "a");
|
||||
assert.equal(out.a, undefined);
|
||||
assert.deepEqual(out.b, { tokenSha256: "hb" });
|
||||
assert.equal(Object.keys(out).filter((k) => k.startsWith("standby-")).length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hook handler", () => {
|
||||
const HANDLER = path.resolve(__dirname, "..", "..", "scripts", "hook-handler.js");
|
||||
const flag = path.join(TMP, "flag.json");
|
||||
const run = (args, file) =>
|
||||
spawnSync(process.execPath, [HANDLER, ...args], {
|
||||
input: "{}",
|
||||
env: { ...process.env, AGENTHUB_QUOTA_FILE: file, CLAUDE_DASHBOARD_PORT: "1" },
|
||||
encoding: "utf8",
|
||||
});
|
||||
|
||||
it("refuses a Claude Code or Codex prompt while the flag says blocked", () => {
|
||||
fs.writeFileSync(
|
||||
flag,
|
||||
JSON.stringify({ blocked: true, until: "2999-01-01T00:00:00Z", message: "quota used up" })
|
||||
);
|
||||
for (const args of [["UserPromptSubmit"], ["UserPromptSubmit", "codex"]]) {
|
||||
const r = run(args, flag);
|
||||
assert.equal(r.status, 2);
|
||||
assert.match(r.stderr, /quota used up/);
|
||||
}
|
||||
});
|
||||
|
||||
it("lets everything else through: other events, Antigravity, an expired or missing flag", () => {
|
||||
assert.equal(run(["PreToolUse"], flag).status, 0);
|
||||
assert.equal(run(["UserPromptSubmit", "antigravity"], flag).status, 0);
|
||||
assert.equal(run(["UserPromptSubmit"], path.join(TMP, "none.json")).status, 0);
|
||||
fs.writeFileSync(flag, JSON.stringify({ blocked: true, until: "2000-01-01T00:00:00Z" }));
|
||||
assert.equal(run(["UserPromptSubmit"], flag).status, 0);
|
||||
fs.writeFileSync(flag, "{not json");
|
||||
assert.equal(run(["UserPromptSubmit"], flag).status, 0);
|
||||
});
|
||||
});
|
||||
@@ -2004,6 +2004,23 @@ db.exec(`
|
||||
PRIMARY KEY (group_id, tool, account)
|
||||
);
|
||||
`);
|
||||
// Spend quotas (server/lib/user-quota.js): user_id 0 is the default every
|
||||
// user without a row of their own gets; a row whose limit_usd is NULL means
|
||||
// "no limit". No foreign key (0 is no user) — a deleted user's row goes with
|
||||
// the trigger. Additive.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS user_quotas (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
limit_usd REAL,
|
||||
period TEXT NOT NULL DEFAULT 'day' CHECK(period IN ('day','5h')),
|
||||
tz_offset INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
CREATE TRIGGER IF NOT EXISTS trg_user_quotas_user_gone AFTER DELETE ON users
|
||||
BEGIN
|
||||
DELETE FROM user_quotas WHERE user_id = OLD.id;
|
||||
END;
|
||||
`);
|
||||
// A database from before the Cursor pool: its `tool` CHECK allowed only
|
||||
// claude/codex. Rebuilt with every pin kept.
|
||||
{
|
||||
|
||||
@@ -872,6 +872,26 @@ function startBackgroundServices() {
|
||||
}, POOL_REFRESH_MS);
|
||||
if (poolTimer.unref) poolTimer.unref();
|
||||
}
|
||||
// Server mode's spend quotas (user-quota.js): who is over theirs now, the
|
||||
// flag files their hooks read, the panes no hook can stop. Off with
|
||||
// DASHBOARD_QUOTA_SWEEP_MS=0; a no-op in local mode.
|
||||
const QUOTA_SWEEP_MS = Number(process.env.DASHBOARD_QUOTA_SWEEP_MS ?? 60_000);
|
||||
if (
|
||||
Number.isFinite(QUOTA_SWEEP_MS) &&
|
||||
QUOTA_SWEEP_MS > 0 &&
|
||||
require("./lib/auth").isServerMode()
|
||||
) {
|
||||
const quotaSweep = () => {
|
||||
try {
|
||||
require("./lib/user-quota").sweep();
|
||||
} catch (err) {
|
||||
console.warn("user quota sweep failed:", err.message);
|
||||
}
|
||||
};
|
||||
const quotaTimer = setInterval(quotaSweep, QUOTA_SWEEP_MS);
|
||||
if (quotaTimer.unref) quotaTimer.unref();
|
||||
setImmediate(quotaSweep);
|
||||
}
|
||||
// Another dashboard on the same database (hooks reach only one of them)
|
||||
// moving a lane: re-broadcast it here. One pragma per beat when nothing
|
||||
// changed — and only while such a dashboard runs at all: alone on its data
|
||||
|
||||
+17
-3
@@ -108,16 +108,29 @@ function refill(t) {
|
||||
while (t.standby.length < STANDBY) t.standby.push(newToken());
|
||||
}
|
||||
|
||||
/** The gateway's view: every queued key and every key a run holds — hashes only. */
|
||||
function clientsFor(t) {
|
||||
/**
|
||||
* The gateway's view: every queued key and every key a run holds — hashes
|
||||
* only. A run whose workspace owner is over their spend quota
|
||||
* (user-quota.js) is left out until it opens again: its omp can no longer
|
||||
* reach a model, while its pane and key stay.
|
||||
*/
|
||||
function clientsFor(t, isBlocked = quotaBlocked) {
|
||||
const out = {};
|
||||
for (const s of t.standby) out[`standby-${sha(s).slice(0, 16)}`] = { tokenSha256: sha(s) };
|
||||
for (const [id, u] of Object.entries(t.used)) {
|
||||
if (u && typeof u.hash === "string") out[id] = { tokenSha256: u.hash };
|
||||
if (u && typeof u.hash === "string" && !isBlocked(id)) out[id] = { tokenSha256: u.hash };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function quotaBlocked(runId) {
|
||||
try {
|
||||
return require("./user-quota").isRunBlocked(runId);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let lastClients = null;
|
||||
|
||||
function save(t) {
|
||||
@@ -519,6 +532,7 @@ module.exports = {
|
||||
takeToken,
|
||||
pruneTokens,
|
||||
clientsFor,
|
||||
clientsFor,
|
||||
readTokens,
|
||||
PROVIDER,
|
||||
TOKEN_ENV,
|
||||
|
||||
@@ -471,6 +471,8 @@ function spawnRun(args) {
|
||||
// their .mcp.json) or a member in someone else's.
|
||||
if (lane) require("./run-as").assertOwnerFits(lane.cwd, laneOwner); // EOWNERFOLDER
|
||||
assertCwdAccount(laneId, cwd); // ECWDACCOUNT
|
||||
// Server mode: an owner over their spend quota starts nothing new (user-quota.js).
|
||||
require("./user-quota").assertSpawnAllowed(laneOwner); // EQUOTA
|
||||
const proxyProfile = resolveProxy(tool, proxy, laneOwner); // EBADPROXY, EUNSUPPORTED
|
||||
// A Claude session on the vendor's own API runs on the least-loaded pool
|
||||
// account, if the admin added any (claude-pool.js) — or the one the
|
||||
@@ -684,6 +686,9 @@ function spawnRun(args) {
|
||||
for (const k of Object.keys(env)) delete env[k];
|
||||
Object.assign(env, own);
|
||||
}
|
||||
// Server mode: where this pane's hooks look for its owner's quota flag
|
||||
// (user-quota.js) — a refused prompt is decided by the hook, locally.
|
||||
Object.assign(env, require("./user-quota").flagEnv(laneOwner, member));
|
||||
|
||||
// `after-start` tools take no prompt on their command line: paste it once
|
||||
// the CLI has had `delayMs` to draw its input box. ponytail: fixed delay,
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
/**
|
||||
* @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,
|
||||
};
|
||||
@@ -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)."),
|
||||
},
|
||||
|
||||
@@ -3041,11 +3041,14 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
// A refused field (EBADMODEL, EBADEFFORT, …) or a thing the lane's tool
|
||||
// cannot do (EUNSUPPORTED: a fork on a non-Claude lane) is the request's fault.
|
||||
// ENOTSHARED (S3): the tool is not under /opt/agenthub/tools yet — a setup state.
|
||||
// EQUOTA (server mode): the workspace owner is over their spend quota.
|
||||
const status = /^(EBAD|EUNSUPPORTED$)/.test(err.code || "")
|
||||
? 400
|
||||
: ["ENOTSHARED", "EMEMBERGONE", "EOWNERFOLDER", "ECWDACCOUNT"].includes(err.code)
|
||||
? 409
|
||||
: 500;
|
||||
: err.code === "EQUOTA"
|
||||
? 429
|
||||
: 500;
|
||||
return res.status(status).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@
|
||||
* own choice; PUT `{tool, account}` chooses one (`null` = automatic) — refused
|
||||
* while an admin pins it (`ELOCKED`) or for an account it may not use
|
||||
* (`ENOACCOUNT`). An account write like the profile's: cookie only, audited.
|
||||
* `/quota` is the account's own spend quota now (server/lib/user-quota.js —
|
||||
* `null` without one, and in local mode), for the status bar.
|
||||
*
|
||||
* S3 (one Linux user per account): an account's dark/light choice also becomes
|
||||
* the theme in their own Claude Code settings, written as them.
|
||||
@@ -170,6 +172,16 @@ router.put("/instructions", sameOriginGuard, (req, res) => {
|
||||
res.json(instructionsView(key));
|
||||
});
|
||||
|
||||
// The account's own spend quota now (server/lib/user-quota.js): `{quota}` —
|
||||
// limit, period, spend, whether it is used up and when it opens again — or
|
||||
// `{quota: null}` with no limit, and always in local mode.
|
||||
router.get("/quota", (req, res) => {
|
||||
const key = userKey(req);
|
||||
if (!key) return unauth(res);
|
||||
const id = auth.isServerMode() ? (req.user || auth.userFromRequest(req))?.id : null;
|
||||
res.json({ quota: id == null ? null : require("../lib/user-quota").statusFor(id) });
|
||||
});
|
||||
|
||||
router.post("/onboarded", sameOriginGuard, (req, res) => {
|
||||
const key = userKey(req);
|
||||
if (!key) return unauth(res);
|
||||
|
||||
@@ -672,6 +672,10 @@ router.post("/", (req, res) => {
|
||||
) {
|
||||
return res.status(409).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
// Server mode: the workspace owner is over their spend quota (user-quota.js).
|
||||
if (err.code === "EQUOTA") {
|
||||
return res.status(429).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
// Nor is a host missing tmux: the request was fine, the dependency is not
|
||||
// installed. 400 would tell the user to fix their input.
|
||||
if (err.code === "ETMUXMISSING") {
|
||||
|
||||
+57
-2
@@ -37,6 +37,12 @@
|
||||
* non-empty and makes that account pick its own at its next login
|
||||
* (`must_change_password`); an admin's own new password must pass every
|
||||
* rule, like Settings → Account's (`validateOwnPassword`, EWEAKPASSWORD).
|
||||
*
|
||||
* Spend quotas (server/lib/user-quota.js): `GET /` also gives each account
|
||||
* its own setting (`quotaSetting`) and the quota binding it now with its spend
|
||||
* (`quota`), plus the `quotaDefault`; `PUT /quota-default` sets or clears the
|
||||
* default and `PATCH /:id` `{quota}` a user's own (`default`, `unlimited` or
|
||||
* `custom`) — account changes like the rest, and each re-sweeps at once.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -130,6 +136,8 @@ router.get("/", (req, res) => {
|
||||
rules
|
||||
);
|
||||
const none = { cost: 0, tokens: 0 };
|
||||
const quota = require("../lib/user-quota");
|
||||
const quotas = quota.statusAll();
|
||||
const users = listUsers().map((u) => ({
|
||||
...u,
|
||||
...(logins.get(u.id) || { logins: 0, lastLoginAt: null }),
|
||||
@@ -138,8 +146,12 @@ router.get("/", (req, res) => {
|
||||
today: today.get(u.id) || none,
|
||||
last7d: last7d.get(u.id) || none,
|
||||
last30d: last30d.get(u.id) || none,
|
||||
// Spend quota (user-quota.js): this user's own setting, and the quota that
|
||||
// binds them now with its spend (null: no limit).
|
||||
quotaSetting: quota.getOverride(u.id),
|
||||
quota: quotas.get(u.id) || null,
|
||||
}));
|
||||
res.json({ users });
|
||||
res.json({ users, quotaDefault: quota.getDefault() });
|
||||
});
|
||||
|
||||
router.get("/storage", (req, res) => {
|
||||
@@ -164,6 +176,7 @@ const STATUS = {
|
||||
EWEAKPASSWORD: 400,
|
||||
EBADROLE: 400,
|
||||
EBADFIELD: 400,
|
||||
EBADQUOTA: 400,
|
||||
EREAUTH: 403,
|
||||
ELOCALMODE: 403,
|
||||
ENOUSER: 404,
|
||||
@@ -196,7 +209,9 @@ function accountChange(handler) {
|
||||
await auth.confirmPassword(req.user, body.currentPassword);
|
||||
const out = await handler(req, body);
|
||||
recordAccountChange(req.user.id, out.user.id, req.method, out.action);
|
||||
res.status(out.status || 200).json({ user: out.user });
|
||||
res
|
||||
.status(out.status || 200)
|
||||
.json({ user: out.user, ...("quotaDefault" in out && { quotaDefault: out.quotaDefault }) });
|
||||
} catch (err) {
|
||||
const status = STATUS[err.code];
|
||||
if (!status) return res.status(500).json({ error: { message: err.message } });
|
||||
@@ -507,6 +522,37 @@ router.post(
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* The default spend quota every user without one of their own gets
|
||||
* (server/lib/user-quota.js): `{quota: {limit_usd, period, tz_offset}}`, or
|
||||
* `{quota: null}` for none. The admin's password again, audited.
|
||||
*/
|
||||
router.put(
|
||||
"/quota-default",
|
||||
accountChange(async (req, body) => {
|
||||
const quota = require("../lib/user-quota");
|
||||
if (body.quota === undefined) {
|
||||
throw Object.assign(new Error("quota: an object or null"), { code: "EBADQUOTA" });
|
||||
}
|
||||
const saved = quota.setDefault(body.quota);
|
||||
quotaSweep();
|
||||
return {
|
||||
user: auth.publicUser(db.prepare("SELECT * FROM users WHERE id = ?").get(req.user.id)),
|
||||
action: `default spend quota: ${saved ? `$${saved.limit_usd}/${saved.period}` : "none"}`,
|
||||
quotaDefault: saved,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
/** Apply a quota change now rather than at the next minute's sweep. */
|
||||
function quotaSweep() {
|
||||
try {
|
||||
require("../lib/user-quota").sweep();
|
||||
} catch (err) {
|
||||
console.warn("user quota sweep failed:", err.message);
|
||||
}
|
||||
}
|
||||
|
||||
router.patch(
|
||||
"/:id",
|
||||
accountChange(async (req, body) => {
|
||||
@@ -542,6 +588,8 @@ router.patch(
|
||||
if (body.signOut !== undefined && body.signOut !== true) {
|
||||
throw Object.assign(new Error("signOut: true"), { code: "EBADFIELD" });
|
||||
}
|
||||
const quotaLib = require("../lib/user-quota");
|
||||
const quota = body.quota === undefined ? null : quotaLib.validateUserQuota(body.quota);
|
||||
const add = laneIds(body.addWorkspaces, "addWorkspaces");
|
||||
const remove = laneIds(body.removeWorkspaces, "removeWorkspaces");
|
||||
const runAs = require("../lib/run-as");
|
||||
@@ -584,6 +632,11 @@ router.patch(
|
||||
auth.endSessions(u.id, keepToken);
|
||||
done.push("signed out everywhere");
|
||||
}
|
||||
if (quota) {
|
||||
quotaLib.setUserQuota(u.id, quota);
|
||||
done.push(`spend quota ${quotaLib.describe(quota)}`);
|
||||
quotaSweep();
|
||||
}
|
||||
if (disabledChange) {
|
||||
auth.setDisabled(u.username, body.disabled);
|
||||
done.push(body.disabled ? "disable" : "enable");
|
||||
@@ -710,3 +763,5 @@ router.post(
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
// Which account each session belongs to, for the spend quotas (server/lib/user-quota.js).
|
||||
module.exports.OWNED_SESSIONS = OWNED_SESSIONS;
|
||||
|
||||
Reference in New Issue
Block a user