Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -721,6 +721,15 @@ INNGEST_EVENT_KEY="local"
# Free tokens granted to each new company on signup (default: 10,000,000)
# TOKEN_SIGNUP_BONUS="10000000"

# ── AI allowance per person ──────────────────────────────────────────
# Monthly cap on the AI tokens (in + out) one person may use on this server's
# own models, summed across all their workspaces; resets on the 1st (UTC).
# Unset or 0 = no server-wide cap. Calls to a workspace's own endpoint never
# count. Workspace admins can set stricter limits in Workspace > Usage and costs.
# AI_MONTHLY_TOKEN_LIMIT_PER_USER="2000000"
# Emails the server-wide cap never applies to (comma-separated).
# AI_TOKEN_LIMIT_EXEMPT_EMAILS="you@example.com"

# ── CORS ─────────────────────────────────────────────────────────────
# Comma-separated list of allowed origins for API CORS headers.
# CORS_ALLOWED_ORIGINS="https://yourdomain.com,https://app.yourdomain.com"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ import { readChatStream, type ChatStep } from "~/lib/chat-stream";

import { makeWorkspaceContext } from "../../../../helpers/workspace-context";

// The AI-allowance gate reads the meter; these tests don't model limits.
jest.mock("~/server/ai-usage/refusal", () => ({
refuseAtAiAllowance: jest.fn(async () => null),
assertWithinAiAllowance: jest.fn(async () => undefined),
}));

jest.mock("~/lib/require-workspace-context", () => {
const actual = jest.requireActual("~/lib/require-workspace-context");
return { ...actual, requireWorkspaceContext: jest.fn() };
Expand Down
6 changes: 6 additions & 0 deletions apps/web/__tests__/api/clientProspector/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@
// Mock the centralized auth+tenant resolver — we control the workspace
// context (or the failure response) per test.
const mockRequireWorkspaceContext = jest.fn();
// The AI-allowance gate reads the meter; these tests don't model limits.
jest.mock("~/server/ai-usage/refusal", () => ({
refuseAtAiAllowance: jest.fn(async () => null),
assertWithinAiAllowance: jest.fn(async () => undefined),
}));

jest.mock("~/lib/require-workspace-context", () =>
jest
.requireActual<
Expand Down Expand Up @@ -75,7 +81,7 @@

// ─── Import route handlers after mocks are set up ────────────────────────────

import { POST, GET } from "~/app/api/client-prospector/route";

Check warning on line 84 in apps/web/__tests__/api/clientProspector/routes.test.ts

View workflow job for this annotation

GitHub Actions / check

'GET' is defined but never used
import { GET as GET_JOB } from "~/app/api/client-prospector/[jobId]/route";

import { makeWorkspaceContext } from "../../helpers/workspace-context";
Expand Down Expand Up @@ -212,7 +218,7 @@
it("returns 400 for missing location", async () => {
mockAuthenticatedUser();

const { location: _, ...bodyWithoutLocation } = VALID_BODY;

Check warning on line 221 in apps/web/__tests__/api/clientProspector/routes.test.ts

View workflow job for this annotation

GitHub Actions / check

'_' is assigned a value but never used
const response = await POST(makePostRequest(bodyWithoutLocation));
const { status, body } = await parseResponse(response);

Expand Down
18 changes: 18 additions & 0 deletions apps/web/__tests__/api/permission-gates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,8 @@ import {
PATCH as editAgent,
} from "~/app/api/collab/agents/[personaId]/route";
import { POST as resetAgent } from "~/app/api/collab/agents/[personaId]/reset/route";
import { GET as memberAllowances } from "~/app/api/settings/usage/members/route";
import { PUT as setMemberLimit } from "~/app/api/settings/usage/members/[userId]/route";

// ---------------------------------------------------------------------------
// Table
Expand Down Expand Up @@ -325,6 +327,16 @@ const GATES: readonly Gate[] = [
renameCategory(json("/api/Categories/1", "PATCH", { name: "Y" }), params({ id: "1" })),
},
// settings.manage
{
route: "PUT /api/settings/usage/members/[userId] (one person's AI limit)",
permission: "settings.manage",
deniedRole: "member",
call: () =>
setMemberLimit(
json("/api/settings/usage/members/u-1", "PUT", { mode: "none" }),
params({ userId: "u-1" })
),
},
{
route: "POST /api/updateUploadPreference",
permission: "settings.manage",
Expand Down Expand Up @@ -642,6 +654,12 @@ const GATES: readonly Gate[] = [
call: () => syncAgentSessions(json("/api/connectors/agent-sessions", "POST", {})),
},
// analytics.view
{
route: "GET /api/settings/usage/members (who used how much AI)",
permission: "analytics.view",
deniedRole: "member",
call: () => memberAllowances(new Request("http://localhost/api/settings/usage/members")),
},
{
route: "GET /api/company/analysis-dashboard",
permission: "analytics.view",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@

const mockRequireWorkspaceContext = jest.fn();

// The AI-allowance gate reads the meter; these tests don't model limits.
jest.mock("~/server/ai-usage/refusal", () => ({
refuseAtAiAllowance: jest.fn(async () => null),
}));
jest.mock("~/server/ai-usage/quota", () => ({
getAiAllowance: jest.fn(async () => ({ blocked: false })),
}));
jest.mock("~/lib/require-workspace-context", () => ({
...jest.requireActual("~/lib/require-workspace-context"),
requireWorkspaceContext: () => mockRequireWorkspaceContext(),
Expand Down
110 changes: 110 additions & 0 deletions apps/web/__tests__/api/settings/usage-members.route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/**
* Workspace › Usage › AI limits per person, the routes: an admin's choice for
* one person is validated before it reaches the store, an unknown person is a
* 404, and the list says whether the viewer may edit. The 403s for members
* live in permission-gates.test.ts.
*/

import type * as MockRequireWorkspaceContext from "../../helpers/mock-require-workspace-context";
import type * as MembersModule from "~/server/ai-usage/members";
import { makeWorkspaceContext } from "../../helpers/workspace-context";

const mockRequireWorkspaceContext = jest.fn();
jest.mock("~/lib/require-workspace-context", () =>
jest
.requireActual<
typeof MockRequireWorkspaceContext
>("../../helpers/mock-require-workspace-context")
.workspaceContextModuleMock(() => mockRequireWorkspaceContext())
);

const mockList = jest.fn();
const mockUpdate = jest.fn();
jest.mock("~/server/ai-usage/members", () => ({
memberLimitChoiceSchema: jest.requireActual<typeof MembersModule>("~/server/ai-usage/members")
.memberLimitChoiceSchema,
memberAllowances: (...args: unknown[]) => mockList(...args),
updateMemberLimit: (...args: unknown[]) => mockUpdate(...args),
}));

import { GET } from "~/app/api/settings/usage/members/route";
import { PUT } from "~/app/api/settings/usage/members/[userId]/route";
import { notFound } from "~/server/workspace/errors";

const params = (userId: string) => ({ params: Promise.resolve({ userId }) });
const put = (body: unknown) =>
new Request("http://localhost/api/settings/usage/members/u-1", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});

function signedInAs(role: string) {
const ctx = makeWorkspaceContext({ role });
mockRequireWorkspaceContext.mockResolvedValue({ success: true, data: ctx });
return ctx;
}

beforeEach(() => {
mockList.mockReset();
mockUpdate.mockReset();
});

describe("PUT /api/settings/usage/members/[userId]", () => {
it.each([
[{ mode: "default" }],
[{ mode: "none" }],
[{ mode: "custom", monthlyTokens: 250_000 }],
])("stores %j for the person", async choice => {
const ctx = signedInAs("admin");
mockUpdate.mockResolvedValue({ authUserId: "u-1", choice });
const response = await PUT(put(choice), params("u-1"));
expect(response.status).toBe(200);
expect(mockUpdate).toHaveBeenCalledWith(ctx, "u-1", choice);
await expect(response.json()).resolves.toEqual({ member: { authUserId: "u-1", choice } });
});

it.each([
[{ mode: "custom", monthlyTokens: 0 }],
[{ mode: "custom", monthlyTokens: 1.5 }],
[{ mode: "custom" }],
[{ mode: "unlimited" }],
])("refuses %j before it reaches the store", async body => {
signedInAs("admin");
const response = await PUT(put(body), params("u-1"));
expect(response.status).toBe(400);
expect(mockUpdate).not.toHaveBeenCalled();
});

it("answers 404 for someone who isn't in the workspace", async () => {
signedInAs("admin");
mockUpdate.mockRejectedValue(notFound());
const response = await PUT(put({ mode: "none" }), params("stranger"));
expect(response.status).toBe(404);
});

it("decodes an encoded user id", async () => {
const ctx = signedInAs("owner");
mockUpdate.mockResolvedValue({});
await PUT(put({ mode: "none" }), params("user%3Aabc"));
expect(mockUpdate).toHaveBeenCalledWith(ctx, "user:abc", { mode: "none" });
});
});

describe("GET /api/settings/usage/members", () => {
it("says whether the viewer may change limits", async () => {
mockList.mockResolvedValue({ members: [] });
signedInAs("admin");
await expect((await GET(new Request("http://localhost/x"))).json()).resolves.toEqual({
members: [],
canEdit: true,
});

const analyst = makeWorkspaceContext({ permissions: ["analytics.view"] });
mockRequireWorkspaceContext.mockResolvedValue({ success: true, data: analyst });
await expect((await GET(new Request("http://localhost/x"))).json()).resolves.toEqual({
members: [],
canEdit: false,
});
});
});
6 changes: 6 additions & 0 deletions apps/web/__tests__/authz/retrieval-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ import { multiDocEnsembleSearch } from "~/server/rag/ensemble";
import { makeWorkspaceContext } from "../helpers/workspace-context";

// Runs the work as-is: which workspace's models it gets is not this test's concern.
// The AI-allowance gate reads the meter; these tests don't model limits.
jest.mock("~/server/ai-usage/refusal", () => ({
refuseAtAiAllowance: jest.fn(async () => null),
assertWithinAiAllowance: jest.fn(async () => undefined),
}));

jest.mock("~/server/workspace-models/scope", () => ({
withWorkspaceChatModels: (_companyId: unknown, _feature: unknown, fn: () => unknown) => fn(),
}));
Expand Down
6 changes: 5 additions & 1 deletion apps/web/__tests__/scenarios/executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,11 @@ it("keeps renewing the lease while a slow unit runs, so polls don't start a seco
const { withWorkspaceChatModels } = jest.requireMock<{
withWorkspaceChatModels: jest.Mock;
}>("~/server/workspace-models/scope");
expect(withWorkspaceChatModels).toHaveBeenCalledWith(5n, "scenarios", expect.any(Function));
expect(withWorkspaceChatModels).toHaveBeenCalledWith(
5n,
expect.objectContaining({ feature: "scenarios", enforce: false }),
expect.any(Function)
);
const renewals = leases.renewLease.mock.calls.length;
await jest.advanceTimersByTimeAsync(LEASE_MS * 2);
expect(leases.renewLease.mock.calls.length).toBe(renewals);
Expand Down
10 changes: 9 additions & 1 deletion apps/web/__tests__/scenarios/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ import { SCENARIO_LIMITS } from "~/lib/scenarios/types";
import { makeWorkspaceContext } from "../helpers/workspace-context";

const mockRequireWorkspaceContext = jest.fn<Promise<WorkspaceContextResult>, []>();
// The AI-allowance gate reads the meter; these tests don't model limits.
jest.mock("~/server/ai-usage/refusal", () => ({
assertWithinAiAllowance: jest.fn(async () => undefined),
}));
jest.mock("~/lib/require-workspace-context", () =>
jest
.requireActual<
Expand Down Expand Up @@ -454,7 +458,11 @@ describe("interviews", () => {
const { withWorkspaceChatModels } = jest.requireMock<{
withWorkspaceChatModels: jest.Mock;
}>("~/server/workspace-models/scope");
expect(withWorkspaceChatModels).toHaveBeenCalledWith(5n, "scenarios", expect.any(Function));
expect(withWorkspaceChatModels).toHaveBeenCalledWith(
5n,
{ feature: "scenarios", userId: "creator" },
expect.any(Function)
);
});
});

Expand Down
Loading
Loading