Skip to content
Open
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
4 changes: 4 additions & 0 deletions apps/dokploy/.env.example
Original file line number Diff line number Diff line change
@@ -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
164 changes: 164 additions & 0 deletions apps/dokploy/__test__/organization/accept-invitation.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
70 changes: 70 additions & 0 deletions apps/dokploy/__test__/organization/membership-limit.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
4 changes: 4 additions & 0 deletions packages/server/src/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down Expand Up @@ -402,6 +403,9 @@ const { handler, api } = betterAuth({
twoFactor(),
organization({
ac,
...(resolveOrgMembershipLimit() !== undefined
? { membershipLimit: resolveOrgMembershipLimit() }
: {}),
roles: {
owner: ownerRole,
admin: adminRole,
Expand Down
13 changes: 13 additions & 0 deletions packages/server/src/lib/membership-limit.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading