feat(server-mode): spend quota per user — no new session, prompts refused once used up

An admin sets a default spend quota on the Users page and overrides it per
user (Edit user → Spend quota: default / no limit / their own), in dollars
per local day or per sliding 5 hours. Spend is the users page's own
hourlyTokenRows + calculateCost over the sessions a user owns.

Over the quota, until it opens again:
- spawnRun refuses every start/resume/home prompt with EQUOTA (429);
- a minute sweep writes each pane's AGENTHUB_QUOTA_FILE (host: data dir,
  member: handoff dir via write-as) and hook-handler.js exits 2 on
  UserPromptSubmit from that file alone (Claude Code, Codex with trusted
  hooks) — fail-open, never asks the dashboard, never for Antigravity;
- a member's omp on the shared login loses its gateway key (clientsFor);
- panes no hook can stop (Antigravity, Cursor, Codex without trusted
  hooks, omp on its own login) are ended.

GET /api/me/quota feeds a "Quota N%" chip in the status bar.
New: server/lib/user-quota.js, user_quotas table, PUT /api/users/quota-default,
PATCH /api/users/:id {quota}, docs in SERVER-MODE.md § Spend quotas.
This commit is contained in:
2026-10-28 14:31:11 +07:00
parent 9e3d6f4ca1
commit fa27b299e7
29 changed files with 2044 additions and 23 deletions
@@ -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")
);
});
});