Skip to content

Commit 6a5ea14

Browse files
committed
Merge remote-tracking branch 'origin/main' into enforce-key-limits-under-user-limits
2 parents ac45d5f + 9e0c1ee commit 6a5ea14

17 files changed

Lines changed: 26638 additions & 35 deletions

File tree

apps/api/src/routes/admin.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,8 @@ const organizationSchema = z.object({
142142
devPlan: z.string(),
143143
// Manual seat-limit override; null = use the plan default.
144144
seats: z.number().int().nullable().optional(),
145+
// Manual API-key-limit override; null = use the plan default.
146+
apiKeyLimit: z.number().int().nullable().optional(),
145147
credits: z.string(),
146148
totalCreditsAllTime: z.string().optional(),
147149
totalSpent: z.string().optional(),
@@ -2023,6 +2025,7 @@ admin.openapi(getOrganizationMetrics, async (c) => {
20232025
plan: org.plan,
20242026
devPlan: org.devPlan,
20252027
seats: org.seats,
2028+
apiKeyLimit: org.apiKeyLimit,
20262029
credits: String(org.credits),
20272030
createdAt: org.createdAt.toISOString(),
20282031
status: org.status,
@@ -2104,6 +2107,7 @@ admin.openapi(getOrganizationTransactions, async (c) => {
21042107
plan: org.plan,
21052108
devPlan: org.devPlan,
21062109
seats: org.seats,
2110+
apiKeyLimit: org.apiKeyLimit,
21072111
credits: String(org.credits),
21082112
createdAt: org.createdAt.toISOString(),
21092113
status: org.status,
@@ -5334,7 +5338,7 @@ admin.openapi(updateReferralBonusRoute, async (c) => {
53345338
});
53355339
});
53365340

5337-
// Manage an organization's plan tier and seat-limit override
5341+
// Manage an organization's plan tier, seat-limit and API-key-limit overrides
53385342
const manageOrganizationRoute = createRoute({
53395343
method: "patch",
53405344
path: "/organizations/{orgId}/manage",
@@ -5349,6 +5353,8 @@ const manageOrganizationRoute = createRoute({
53495353
plan: z.enum(["free", "pro", "enterprise"]),
53505354
// Null clears the override and reverts to the plan default.
53515355
seats: z.number().int().min(0).max(100000).nullable(),
5356+
// Null clears the override and reverts to the plan default.
5357+
apiKeyLimit: z.number().int().min(0).max(100000).nullable(),
53525358
}),
53535359
},
53545360
},
@@ -5362,6 +5368,7 @@ const manageOrganizationRoute = createRoute({
53625368
message: z.string(),
53635369
plan: z.string(),
53645370
seats: z.number().int().nullable(),
5371+
apiKeyLimit: z.number().int().nullable(),
53655372
}),
53665373
},
53675374
},
@@ -5383,7 +5390,7 @@ const manageOrganizationRoute = createRoute({
53835390
admin.openapi(manageOrganizationRoute, async (c) => {
53845391
const user = c.get("user");
53855392
const { orgId } = c.req.valid("param");
5386-
const { plan, seats } = c.req.valid("json");
5393+
const { plan, seats, apiKeyLimit } = c.req.valid("json");
53875394

53885395
const org = await db.query.organization.findFirst({
53895396
where: {
@@ -5402,6 +5409,7 @@ admin.openapi(manageOrganizationRoute, async (c) => {
54025409
.set({
54035410
plan,
54045411
seats,
5412+
apiKeyLimit,
54055413
})
54065414
.where(eq(tables.organization.id, orgId));
54075415

@@ -5416,13 +5424,16 @@ admin.openapi(manageOrganizationRoute, async (c) => {
54165424
newPlan: plan,
54175425
previousSeats: org.seats,
54185426
newSeats: seats,
5427+
previousApiKeyLimit: org.apiKeyLimit,
5428+
newApiKeyLimit: apiKeyLimit,
54195429
},
54205430
});
54215431

54225432
return c.json({
54235433
message: "Organization updated successfully",
54245434
plan,
54255435
seats,
5436+
apiKeyLimit,
54265437
});
54275438
});
54285439

apps/api/src/routes/keys-api.spec.ts

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -829,9 +829,10 @@ describe("keys route", () => {
829829
expect(reactivated?.expiresAt?.toISOString()).toBe(newExpiry);
830830
});
831831

832-
test("POST /keys/api should enforce API key limit of 20", async () => {
833-
// Create 19 more API keys to reach the limit of 20
834-
for (let i = 2; i <= 20; i++) {
832+
test("POST /keys/api enforces the org-wide free-plan limit of 5", async () => {
833+
// beforeEach already created 1 active key; add 4 more to reach the free
834+
// org-wide cap of 5 active keys.
835+
for (let i = 2; i <= 5; i++) {
835836
await db.insert(tables.apiKey).values({
836837
id: `test-api-key-id-${i}`,
837838
token: `test-token-${i}`,
@@ -842,15 +843,15 @@ describe("keys route", () => {
842843
});
843844
}
844845

845-
// Try to create the 21st API key, should fail
846+
// Try to create the 6th API key, should fail
846847
const res = await app.request("/keys/api", {
847848
method: "POST",
848849
headers: {
849850
"Content-Type": "application/json",
850851
Cookie: token,
851852
},
852853
body: JSON.stringify({
853-
description: "Twenty-first API Key",
854+
description: "Sixth API Key",
854855
projectId: "test-project-id",
855856
usageLimit: null,
856857
}),
@@ -859,6 +860,44 @@ describe("keys route", () => {
859860
expect(res.status).toBe(400);
860861
const json = await res.json();
861862
expect(json.message).toContain("API key limit reached");
862-
expect(json.message).toContain("Maximum 20 API keys per project");
863+
expect(json.message).toContain(
864+
"Maximum 5 active API keys per organization",
865+
);
866+
});
867+
868+
test("POST /keys/api respects the admin apiKeyLimit override", async () => {
869+
await db
870+
.update(tables.organization)
871+
.set({ apiKeyLimit: 2 })
872+
.where(eq(tables.organization.id, "test-org-id"));
873+
874+
// beforeEach created 1 active key; add 1 more to reach the override of 2.
875+
await db.insert(tables.apiKey).values({
876+
id: "test-api-key-id-2",
877+
token: "test-token-2",
878+
projectId: "test-project-id",
879+
description: "Test API Key 2",
880+
status: "active",
881+
createdBy: "test-user-id",
882+
});
883+
884+
const res = await app.request("/keys/api", {
885+
method: "POST",
886+
headers: {
887+
"Content-Type": "application/json",
888+
Cookie: token,
889+
},
890+
body: JSON.stringify({
891+
description: "Third API Key",
892+
projectId: "test-project-id",
893+
usageLimit: null,
894+
}),
895+
});
896+
897+
expect(res.status).toBe(400);
898+
const json = await res.json();
899+
expect(json.message).toContain(
900+
"Maximum 2 active API keys per organization",
901+
);
863902
});
864903
});

apps/api/src/routes/keys-api.ts

Lines changed: 51 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -792,6 +792,18 @@ export const createIamRuleSchema = z.object({
792792
status: iamRuleStatusEnum.default("active"),
793793
});
794794

795+
// Org-wide cap on active developer API keys. An explicit `organization.apiKeyLimit`
796+
// override (set by admins) always takes precedence over these plan defaults.
797+
export function resolveApiKeyLimit(
798+
plan: string | null | undefined,
799+
apiKeyLimit: number | null | undefined,
800+
): number {
801+
if (apiKeyLimit !== null && apiKeyLimit !== undefined) {
802+
return apiKeyLimit;
803+
}
804+
return plan === "enterprise" ? 500 : plan === "pro" ? 20 : 5;
805+
}
806+
795807
// Create a new API key
796808
const create = createRoute({
797809
method: "post",
@@ -955,26 +967,37 @@ export async function createApiKeyForProject(
955967
});
956968
}
957969

958-
const existingApiKeys = await db.query.apiKey.findMany({
970+
const orgProjects = await db.query.project.findMany({
971+
where: { organizationId: { eq: project.organization.id } },
972+
columns: { id: true },
973+
});
974+
const orgProjectIds = orgProjects.map((p) => p.id);
975+
976+
// Org-wide cap on active developer API keys across all of the org's projects.
977+
// Platform and hidden LLM SDK aggregate keys are excluded (keyType: "user").
978+
const orgActiveApiKeys = await db.query.apiKey.findMany({
959979
where: {
960-
projectId: { eq: projectId },
961-
status: { ne: "deleted" },
962-
// Only count developer keys toward the per-project cap; platform and
963-
// hidden LLM SDK aggregate keys are excluded.
980+
projectId: { in: orgProjectIds },
981+
status: { eq: "active" },
964982
keyType: { eq: "user" },
965983
},
984+
columns: { id: true },
966985
});
967986

968-
const maxApiKeys = project.organization.plan === "enterprise" ? 500 : 20;
987+
const maxApiKeys = resolveApiKeyLimit(
988+
project.organization.plan,
989+
project.organization.apiKeyLimit,
990+
);
969991

970-
if (existingApiKeys.length >= maxApiKeys) {
992+
if (orgActiveApiKeys.length >= maxApiKeys) {
971993
throw new HTTPException(400, {
972-
message: `API key limit reached. Maximum ${maxApiKeys} API keys per project. Contact us at contact@llmgateway.io to unlock more.`,
994+
message: `API key limit reached. Maximum ${maxApiKeys} active API keys per organization. Contact us at contact@llmgateway.io to unlock more.`,
973995
});
974996
}
975997

976-
// Enforce the active-key cap. The creator's own per-member cap wins; for
977-
// developers with no explicit cap the org-wide default developer cap applies.
998+
// Enforce the per-member active-key cap. The creator's own per-member cap
999+
// wins; for developers with no explicit cap the org-wide default developer
1000+
// cap applies.
9781001
const creatorMembership = await db.query.userOrganization.findFirst({
9791002
where: {
9801003
userId: { eq: userId },
@@ -1013,12 +1036,6 @@ export async function createApiKeyForProject(
10131036
: null);
10141037

10151038
if (typeof effectiveMaxApiKeys === "number") {
1016-
const orgProjects = await db.query.project.findMany({
1017-
where: { organizationId: { eq: project.organization.id } },
1018-
columns: { id: true },
1019-
});
1020-
const orgProjectIds = orgProjects.map((p) => p.id);
1021-
10221039
const memberActiveKeys = await db.query.apiKey.findMany({
10231040
where: {
10241041
createdBy: { eq: userId },
@@ -1234,7 +1251,9 @@ keysApi.openapi(list, async (c) => {
12341251
},
12351252
});
12361253

1237-
// Get organization plan info if projectId is specified
1254+
// Get organization plan info if projectId is specified. The cap is org-wide,
1255+
// so currentCount counts active developer keys across ALL of the org's
1256+
// projects, not just the selected one.
12381257
let currentCount = 0;
12391258
let maxKeys = 0;
12401259
let plan: "free" | "pro" | "enterprise" = "free";
@@ -1253,8 +1272,21 @@ keysApi.openapi(list, async (c) => {
12531272

12541273
if (project?.organization) {
12551274
plan = project.organization.plan as "free" | "pro" | "enterprise";
1256-
maxKeys = plan === "enterprise" ? 500 : plan === "pro" ? 20 : 5;
1257-
currentCount = apiKeys.filter((key) => key.status !== "deleted").length;
1275+
maxKeys = resolveApiKeyLimit(plan, project.organization.apiKeyLimit);
1276+
1277+
const orgProjects = await db.query.project.findMany({
1278+
where: { organizationId: { eq: project.organization.id } },
1279+
columns: { id: true },
1280+
});
1281+
const orgActiveKeys = await db.query.apiKey.findMany({
1282+
where: {
1283+
projectId: { in: orgProjects.map((p) => p.id) },
1284+
status: { eq: "active" },
1285+
keyType: { eq: "user" },
1286+
},
1287+
columns: { id: true },
1288+
});
1289+
currentCount = orgActiveKeys.length;
12581290
}
12591291
}
12601292

apps/api/src/routes/organization.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ const organizationSchema = z.object({
5959
planExpiresAt: z.date().nullable(),
6060
// Manual seat-limit override; null = use the plan default.
6161
seats: z.number().nullable(),
62+
// Manual API-key-limit override; null = use the plan default.
63+
apiKeyLimit: z.number().nullable(),
6264
retentionLevel: z.enum(["retain", "none"]),
6365
providerCompliancePolicy: providerCompliancePolicySchema.nullable(),
6466
status: z.enum(["active", "inactive", "deleted"]).nullable(),

apps/api/src/routes/sso.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,7 @@ const registerBodySchema = z.object({
256256
cert: z.string().trim().min(1).openapi({
257257
description: "IdP X.509 signing certificate (PEM or base64 body)",
258258
}),
259-
enforced: z.boolean().default(true).openapi({
259+
enforced: z.boolean().default(false).openapi({
260260
description:
261261
"When true, users whose email domain matches may only sign in via SSO",
262262
}),

apps/gateway/src/lib/rate-limit.spec.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ describe("Rate Limiting", () => {
9191
plan: "free" as const,
9292
planExpiresAt: null,
9393
seats: null,
94+
apiKeyLimit: null,
9495
subscriptionCancelled: false,
9596
trialStartDate: null,
9697
trialEndDate: null,
@@ -180,6 +181,7 @@ describe("Rate Limiting", () => {
180181
plan: "free" as const,
181182
planExpiresAt: null,
182183
seats: null,
184+
apiKeyLimit: null,
183185
subscriptionCancelled: false,
184186
trialStartDate: null,
185187
trialEndDate: null,
@@ -270,6 +272,7 @@ describe("Rate Limiting", () => {
270272
plan: "free" as const,
271273
planExpiresAt: null,
272274
seats: null,
275+
apiKeyLimit: null,
273276
subscriptionCancelled: false,
274277
trialStartDate: null,
275278
trialEndDate: null,
@@ -354,6 +357,7 @@ describe("Rate Limiting", () => {
354357
plan: "free" as const,
355358
planExpiresAt: null,
356359
seats: null,
360+
apiKeyLimit: null,
357361
subscriptionCancelled: false,
358362
trialStartDate: null,
359363
trialEndDate: null,
@@ -446,6 +450,7 @@ describe("Rate Limiting", () => {
446450
plan: "free" as const,
447451
planExpiresAt: null,
448452
seats: null,
453+
apiKeyLimit: null,
449454
subscriptionCancelled: false,
450455
trialStartDate: null,
451456
trialEndDate: null,
@@ -538,6 +543,7 @@ describe("Rate Limiting", () => {
538543
plan: "free" as const,
539544
planExpiresAt: null,
540545
seats: null,
546+
apiKeyLimit: null,
541547
subscriptionCancelled: false,
542548
trialStartDate: null,
543549
trialEndDate: null,

apps/ui/src/components/api-keys/api-keys-client.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ export function ApiKeysClient({ initialData }: { initialData: ApiKey[] }) {
9595
}
9696
disabledMessage={
9797
planLimits
98-
? `${planLimits.plan === "enterprise" ? "Enterprise" : planLimits.plan === "pro" ? "Pro" : "Free"} plan allows maximum ${planLimits.maxKeys} API keys per project`
98+
? `${planLimits.plan === "enterprise" ? "Enterprise" : planLimits.plan === "pro" ? "Pro" : "Free"} plan allows maximum ${planLimits.maxKeys} API keys per organization`
9999
: undefined
100100
}
101101
>

apps/ui/src/components/api-keys/api-keys-list.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -462,7 +462,7 @@ export function ApiKeysList({
462462
}
463463
disabledMessage={
464464
planLimits
465-
? `${planLimits.plan === "enterprise" ? "Enterprise" : planLimits.plan === "pro" ? "Pro" : "Free"} plan allows maximum ${planLimits.maxKeys} API keys per project`
465+
? `${planLimits.plan === "enterprise" ? "Enterprise" : planLimits.plan === "pro" ? "Pro" : "Free"} plan allows maximum ${planLimits.maxKeys} API keys per organization`
466466
: undefined
467467
}
468468
>

apps/ui/src/components/dashboard/dashboard-client.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,7 @@ export function DashboardClient({
309309
}
310310
disabledMessage={
311311
planLimits
312-
? `${planLimits.plan === "enterprise" ? "Enterprise" : planLimits.plan === "pro" ? "Pro" : "Free"} plan allows maximum ${planLimits.maxKeys} API keys per project`
312+
? `${planLimits.plan === "enterprise" ? "Enterprise" : planLimits.plan === "pro" ? "Pro" : "Free"} plan allows maximum ${planLimits.maxKeys} API keys per organization`
313313
: undefined
314314
}
315315
>

apps/ui/src/components/sso/sso-client.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ export function SsoClient() {
132132
const [domain, setDomain] = useState("");
133133
const [entryPoint, setEntryPoint] = useState("");
134134
const [cert, setCert] = useState("");
135-
const [enforced, setEnforced] = useState(true);
135+
const [enforced, setEnforced] = useState(false);
136136
const [generatedToken, setGeneratedToken] = useState<string | null>(null);
137137
const [groupName, setGroupName] = useState("");
138138
const [role, setRole] = useState<"owner" | "admin" | "developer">(

0 commit comments

Comments
 (0)