diff --git a/apps/dokploy/.env.example b/apps/dokploy/.env.example index 8f801196e7..587307de80 100644 --- a/apps/dokploy/.env.example +++ b/apps/dokploy/.env.example @@ -1,3 +1,7 @@ DATABASE_URL="postgres://dokploy:amukds4wi9001583845717ad2@localhost:5432/dokploy" PORT=3000 NODE_ENV=development + +# Maximum members per organization (better-auth default: 100). +# Set a high value to effectively disable the limit. +# ORG_MEMBERSHIP_LIMIT=10000 diff --git a/apps/dokploy/__test__/organization/accept-invitation.test.ts b/apps/dokploy/__test__/organization/accept-invitation.test.ts new file mode 100644 index 0000000000..c50ba6975c --- /dev/null +++ b/apps/dokploy/__test__/organization/accept-invitation.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it, vi } from "vitest"; + +/** + * Integration-style tests that verify the membership limit enforcement logic + * as implemented by better-auth's organization plugin. + * + * These tests replicate the exact condition from: + * better-auth/dist/plugins/organization/routes/crud-invites.mjs + * better-auth/dist/plugins/organization/routes/crud-members.mjs + * + * The condition is: + * const membershipLimit = ctx.context.orgOptions?.membershipLimit || 100; + * if (membersCount >= membershipLimit) throw ORGANIZATION_MEMBERSHIP_LIMIT_REACHED; + */ + +function simulateAcceptInvitation(opts: { + membershipLimit: number | undefined; + currentMemberCount: number; +}): { allowed: boolean; effectiveLimit: number } { + const effectiveLimit = opts.membershipLimit || 100; + const allowed = opts.currentMemberCount < effectiveLimit; + return { allowed, effectiveLimit }; +} + +describe("accept-invitation membership limit enforcement", () => { + describe("with default limit (no ORG_MEMBERSHIP_LIMIT env)", () => { + it("allows acceptance when org has fewer than 100 members", () => { + const result = simulateAcceptInvitation({ + membershipLimit: undefined, + currentMemberCount: 50, + }); + expect(result.allowed).toBe(true); + expect(result.effectiveLimit).toBe(100); + }); + + it("blocks acceptance when org has exactly 100 members", () => { + const result = simulateAcceptInvitation({ + membershipLimit: undefined, + currentMemberCount: 100, + }); + expect(result.allowed).toBe(false); + expect(result.effectiveLimit).toBe(100); + }); + + it("blocks acceptance when org has more than 100 members", () => { + const result = simulateAcceptInvitation({ + membershipLimit: undefined, + currentMemberCount: 150, + }); + expect(result.allowed).toBe(false); + }); + + it("allows acceptance when org has exactly 99 members", () => { + const result = simulateAcceptInvitation({ + membershipLimit: undefined, + currentMemberCount: 99, + }); + expect(result.allowed).toBe(true); + }); + }); + + describe("with custom limit via ORG_MEMBERSHIP_LIMIT", () => { + it("allows acceptance when limit is 500 and org has 100 members", () => { + const result = simulateAcceptInvitation({ + membershipLimit: 500, + currentMemberCount: 100, + }); + expect(result.allowed).toBe(true); + expect(result.effectiveLimit).toBe(500); + }); + + it("blocks acceptance when limit is 500 and org has 500 members", () => { + const result = simulateAcceptInvitation({ + membershipLimit: 500, + currentMemberCount: 500, + }); + expect(result.allowed).toBe(false); + }); + + it("allows acceptance with very high limit (10000) regardless of count", () => { + const result = simulateAcceptInvitation({ + membershipLimit: 10000, + currentMemberCount: 9999, + }); + expect(result.allowed).toBe(true); + expect(result.effectiveLimit).toBe(10000); + }); + + it("blocks acceptance when limit is 10000 and count reaches it", () => { + const result = simulateAcceptInvitation({ + membershipLimit: 10000, + currentMemberCount: 10000, + }); + expect(result.allowed).toBe(false); + }); + }); + + describe("interaction between resolveOrgMembershipLimit and better-auth", () => { + it("resolveOrgMembershipLimit returns undefined → better-auth uses 100", async () => { + const originalEnv = process.env.ORG_MEMBERSHIP_LIMIT; + delete process.env.ORG_MEMBERSHIP_LIMIT; + + const { resolveOrgMembershipLimit } = await import( + "@dokploy/server/lib/membership-limit" + ); + const limit = resolveOrgMembershipLimit(); + const result = simulateAcceptInvitation({ + membershipLimit: limit, + currentMemberCount: 100, + }); + + expect(limit).toBeUndefined(); + expect(result.allowed).toBe(false); + expect(result.effectiveLimit).toBe(100); + + if (originalEnv !== undefined) { + process.env.ORG_MEMBERSHIP_LIMIT = originalEnv; + } + }); + + it("resolveOrgMembershipLimit returns 500 → better-auth uses 500", async () => { + const originalEnv = process.env.ORG_MEMBERSHIP_LIMIT; + process.env.ORG_MEMBERSHIP_LIMIT = "500"; + + vi.resetModules(); + const { resolveOrgMembershipLimit } = await import( + "@dokploy/server/lib/membership-limit" + ); + const limit = resolveOrgMembershipLimit(); + const result = simulateAcceptInvitation({ + membershipLimit: limit, + currentMemberCount: 100, + }); + + expect(limit).toBe(500); + expect(result.allowed).toBe(true); + expect(result.effectiveLimit).toBe(500); + + if (originalEnv !== undefined) { + process.env.ORG_MEMBERSHIP_LIMIT = originalEnv; + } else { + delete process.env.ORG_MEMBERSHIP_LIMIT; + } + }); + }); + + describe("add-member endpoint uses same limit logic", () => { + it("adding a member is blocked at default limit of 100", () => { + const result = simulateAcceptInvitation({ + membershipLimit: undefined, + currentMemberCount: 100, + }); + expect(result.allowed).toBe(false); + }); + + it("adding a member is allowed with custom limit above current count", () => { + const result = simulateAcceptInvitation({ + membershipLimit: 200, + currentMemberCount: 100, + }); + expect(result.allowed).toBe(true); + }); + }); +}); diff --git a/apps/dokploy/__test__/organization/membership-limit.test.ts b/apps/dokploy/__test__/organization/membership-limit.test.ts new file mode 100644 index 0000000000..35649db9d8 --- /dev/null +++ b/apps/dokploy/__test__/organization/membership-limit.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +describe("resolveOrgMembershipLimit", () => { + const originalEnv = process.env; + + afterEach(() => { + process.env = originalEnv; + vi.resetModules(); + }); + + async function importFresh() { + const mod = await import("@dokploy/server/lib/membership-limit"); + return mod.resolveOrgMembershipLimit; + } + + it("returns undefined when ORG_MEMBERSHIP_LIMIT is not set", async () => { + process.env = { ...originalEnv }; + delete process.env.ORG_MEMBERSHIP_LIMIT; + const resolveOrgMembershipLimit = await importFresh(); + expect(resolveOrgMembershipLimit()).toBeUndefined(); + }); + + it("returns undefined for empty string", async () => { + process.env = { ...originalEnv, ORG_MEMBERSHIP_LIMIT: "" }; + const resolveOrgMembershipLimit = await importFresh(); + expect(resolveOrgMembershipLimit()).toBeUndefined(); + }); + + it("returns the parsed number for a valid positive integer", async () => { + process.env = { ...originalEnv, ORG_MEMBERSHIP_LIMIT: "500" }; + const resolveOrgMembershipLimit = await importFresh(); + expect(resolveOrgMembershipLimit()).toBe(500); + }); + + it("returns the parsed number for a large value (effectively unlimited)", async () => { + process.env = { ...originalEnv, ORG_MEMBERSHIP_LIMIT: "10000" }; + const resolveOrgMembershipLimit = await importFresh(); + expect(resolveOrgMembershipLimit()).toBe(10000); + }); + + it("returns undefined for zero", async () => { + process.env = { ...originalEnv, ORG_MEMBERSHIP_LIMIT: "0" }; + const resolveOrgMembershipLimit = await importFresh(); + expect(resolveOrgMembershipLimit()).toBeUndefined(); + }); + + it("returns undefined for negative numbers", async () => { + process.env = { ...originalEnv, ORG_MEMBERSHIP_LIMIT: "-5" }; + const resolveOrgMembershipLimit = await importFresh(); + expect(resolveOrgMembershipLimit()).toBeUndefined(); + }); + + it("returns undefined for non-numeric strings", async () => { + process.env = { ...originalEnv, ORG_MEMBERSHIP_LIMIT: "abc" }; + const resolveOrgMembershipLimit = await importFresh(); + expect(resolveOrgMembershipLimit()).toBeUndefined(); + }); + + it("returns undefined for NaN-producing values like Infinity", async () => { + process.env = { ...originalEnv, ORG_MEMBERSHIP_LIMIT: "Infinity" }; + const resolveOrgMembershipLimit = await importFresh(); + expect(resolveOrgMembershipLimit()).toBeUndefined(); + }); + + it("handles decimal values by returning them as-is", async () => { + process.env = { ...originalEnv, ORG_MEMBERSHIP_LIMIT: "150.5" }; + const resolveOrgMembershipLimit = await importFresh(); + expect(resolveOrgMembershipLimit()).toBe(150.5); + }); +}); diff --git a/packages/server/src/lib/auth.ts b/packages/server/src/lib/auth.ts index c8dbf18073..ce5d2c50a2 100644 --- a/packages/server/src/lib/auth.ts +++ b/packages/server/src/lib/auth.ts @@ -28,6 +28,7 @@ import { import { getPublicIpWithFallback } from "../wss/utils"; import { ac, adminRole, memberRole, ownerRole } from "./access-control"; import { betterAuthSecret } from "./auth-secret"; +import { resolveOrgMembershipLimit } from "./membership-limit"; const { handler, api } = betterAuth({ database: drizzleAdapter(db, { @@ -402,6 +403,9 @@ const { handler, api } = betterAuth({ twoFactor(), organization({ ac, + ...(resolveOrgMembershipLimit() !== undefined + ? { membershipLimit: resolveOrgMembershipLimit() } + : {}), roles: { owner: ownerRole, admin: adminRole, diff --git a/packages/server/src/lib/membership-limit.ts b/packages/server/src/lib/membership-limit.ts new file mode 100644 index 0000000000..e7f220d04d --- /dev/null +++ b/packages/server/src/lib/membership-limit.ts @@ -0,0 +1,13 @@ +/** + * Resolves the organization membership limit from the environment. + * + * - If ORG_MEMBERSHIP_LIMIT is set to a valid positive number, returns that number. + * - Otherwise returns undefined, letting better-auth use its built-in default (100). + */ +export function resolveOrgMembershipLimit(): number | undefined { + const raw = process.env.ORG_MEMBERSHIP_LIMIT; + if (!raw) return undefined; + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed <= 0) return undefined; + return parsed; +}