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
28 changes: 27 additions & 1 deletion packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import type {
} from '@objectstack/spec/system';
import type { IDataEngine } from '@objectstack/core';
import type { IEmailService } from '@objectstack/spec/contracts';
import { readEnvWithDeprecation, resolveMultiOrgEnabled } from '@objectstack/types';
import { readEnvWithDeprecation, resolveMultiOrgEnabled, resolveOrgLimit } from '@objectstack/types';
import { mapMembershipRole, BUILTIN_ROLE_PLATFORM_ADMIN } from '@objectstack/spec';
import { createObjectQLAdapterFactory, withSystemReadContext } from './objectql-adapter.js';
import {
Expand Down Expand Up @@ -910,6 +910,32 @@ export class AuthManager {
// the built-in /accept-invitation route usable for pilots; operators
// who wire a real mailer can re-enable downstream.
requireEmailVerificationOnInvitation: false,
// Cap how many orgs a user can CREATE (OS_ORG_LIMIT). Counts only orgs
// the user OWNS (role=owner) — never orgs they were merely invited into —
// so a generous cap stops scripted org/free-env spam (each new org can
// auto-provision a free environment on the cloud control plane) WITHOUT
// ever blocking a collaborator who belongs to many orgs. Unset → no
// limit (self-host default). Fail-open: if the count can't be taken we
// allow creation rather than block a legitimate user on an infra hiccup.
organizationLimit: async (user: { id?: string }) => {
const limit = resolveOrgLimit();
if (limit == null) return false;
const engine = this.config.dataEngine;
const uid = typeof user?.id === 'string' ? user.id : '';
if (!engine || !uid) return false;
try {
// `sys_member` is tenant-scoped (organization_id). We need to count
// the user's owned orgs ACROSS tenants, so read with the system
// context (isSystem) to bypass org-scoping — otherwise the query
// returns nothing and the limit never fires.
const owned = await withSystemReadContext(engine).count('sys_member', {
where: { user_id: uid, role: 'owner' },
});
return (owned ?? 0) >= limit;
} catch {
return false;
}
},
...(customOrgRoles ? { roles: customOrgRoles } : {}),
// ── Slug-change guard ─────────────────────────────────────
// An org's slug is baked into every env hostname at creation
Expand Down
19 changes: 19 additions & 0 deletions packages/types/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,25 @@ export function resolveMultiOrgEnabled(): boolean {
return String(raw ?? 'false').toLowerCase() !== 'false';
}

/**
* Maximum number of organizations a single user may CREATE, from `OS_ORG_LIMIT`.
* The auth plugin forwards this as better-auth's `organizationLimit` in function
* form, counting only the caller's `role=owner` memberships — so it caps
* self-created orgs (each of which can auto-provision a free environment on the
* cloud control plane) without penalising a user invited into many orgs.
*
* Only meaningful when multi-org is enabled ({@link resolveMultiOrgEnabled}).
* Returns `undefined` when unset or non-positive → no limit (better-auth treats
* an absent `organizationLimit` as unlimited), preserving self-host behaviour.
* Deployments that let users self-create orgs SHOULD set a generous cap.
*/
export function resolveOrgLimit(): number | undefined {
const raw = readEnvWithDeprecation('OS_ORG_LIMIT', [], { silent: true });
if (raw == null || String(raw).trim() === '') return undefined;
const n = Number.parseInt(String(raw), 10);
return Number.isFinite(n) && n > 0 ? n : undefined;
}

/**
* Internal: clear the dedupe set. Test-only; exposed so suite-wide
* deprecation warnings don't bleed between tests.
Expand Down