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
4 changes: 2 additions & 2 deletions docs/license-faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,9 @@ This split (AGPL core + commercial `ee/`) is the same model used by Cal.com and

---

## Community and Business
## Community, Starter, Team and Business

A self-hosted instance runs **Community** unless a licence key is activated. Community includes up to 3 active users. **Business** adds more users, single sign-on (Entra ID, Google, Okta, OIDC) and SCIM provisioning; administrators can try it for 30 days under **Settings → License**. See [anythingmcp.com/pricing](https://anythingmcp.com/pricing).
A self-hosted instance runs **Community** unless a licence key is activated. Community is meant for personal use, education and evaluation, and includes up to 3 active users. **Starter** and **Team** license an instance for small teams at work. **Business** adds more users, single sign-on (Entra ID, Google, Okta, OIDC) and SCIM provisioning; administrators can try it for 30 days under **Settings → License**. See [anythingmcp.com/pricing](https://anythingmcp.com/pricing).

---

Expand Down
30 changes: 29 additions & 1 deletion packages/backend/src/ee/licensing/edition.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ describe('EditionService', () => {
license: { plan: 'business', status: 'active', expiresAt: null, features: { maxUsers: 10 } },
});
const s = await service.getState();
expect(s).toMatchObject({ edition: 'business', source: 'license', seatLimit: 10, trialAvailable: false });
expect(s).toMatchObject({ edition: 'business', business: true, source: 'license', seatLimit: 10, trialAvailable: false });
});

it('never gives a paid licence fewer users than Community', async () => {
Expand All @@ -74,6 +74,34 @@ describe('EditionService', () => {
expect((await service.getState()).seatLimit).toBe(COMMUNITY_SEAT_LIMIT);
});

it('licenses Starter and Team without SSO or SCIM, and still offers the Business trial', async () => {
for (const plan of ['starter', 'team']) {
const { service } = make({
license: { plan, status: 'active', expiresAt: null, features: { maxUsers: 3 } },
});
expect(await service.getState()).toMatchObject({
edition: plan,
business: false,
source: 'license',
seatLimit: COMMUNITY_SEAT_LIMIT,
trialAvailable: true,
});
}
});

it('lifts the user limit of a Starter key during the Business trial', async () => {
const { service } = make({
license: { plan: 'starter', status: 'active', expiresAt: null, features: { maxUsers: 1 } },
settings: { business_trial_ends_at: new Date(Date.now() + DAY).toISOString() },
});
expect(await service.getState()).toMatchObject({ edition: 'starter', business: true, source: 'trial', seatLimit: null });
});

it('refuses Business capabilities to a Starter key', async () => {
const { service } = make({ license: { plan: 'team', status: 'active', expiresAt: null } });
await expect(service.assertBusiness('Single sign-on')).rejects.toBeInstanceOf(ForbiddenException);
});

it('has no user limit when the licence carries none', async () => {
const { service } = make({
license: { plan: 'enterprise', status: 'active', expiresAt: null, features: { maxUsers: null } },
Expand Down
36 changes: 23 additions & 13 deletions packages/backend/src/ee/licensing/edition.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,18 @@ export const BUSINESS_TRIAL_DAYS = 30;
export const TRANSITION_DAYS = 60;

const PAID_PLANS = new Set(['starter', 'team', 'business', 'enterprise']);
/** Paid plans that include single sign-on, SCIM and role sync. */
const BUSINESS_PLANS = new Set(['business', 'enterprise']);
const TRIAL_KEY = 'business_trial_ends_at';
const TRANSITION_KEY = 'business_transition_until';
const DAY_MS = 24 * 60 * 60 * 1000;

export type Edition = 'cloud' | 'community' | 'business';
export type Edition = 'cloud' | 'community' | 'starter' | 'team' | 'business' | 'enterprise';
export type BusinessSource = 'license' | 'trial' | 'transition' | null;

export interface EditionState {
edition: Edition;
/** Whether Business capabilities (SSO, SCIM, more users) are available. */
/** Whether Business capabilities (SSO, SCIM, role sync) are available. */
business: boolean;
source: BusinessSource;
/** Plan of the activated licence key, if any. */
Expand All @@ -53,9 +55,11 @@ export const SEAT_LIMIT = 'seat_limit';
/**
* Which edition a self-hosted instance runs, and what that allows.
*
* Community is the default. Business comes from an activated licence key,
* from the one-time evaluation, or from the transition period of an instance
* that already relied on Business capabilities when it was upgraded.
* Community is the default. A Starter or Team key licenses the instance for
* its users. Business capabilities (SSO, SCIM, role sync) come from a
* Business or Enterprise key, from the one-time evaluation, or from the
* transition period of an instance that already relied on them when it was
* upgraded.
*
* The rules never take away something that already works: existing users stay
* active above the limit, existing single sign-on identities keep signing in,
Expand Down Expand Up @@ -136,32 +140,38 @@ export class EditionService implements OnModuleInit {
const trialActive = !!trialEnds && trialEnds > now;
const transitionActive = !!transitionUntil && transitionUntil > now;

const source: BusinessSource = paid
const businessKey = paid && BUSINESS_PLANS.has(license!.plan);
const source: BusinessSource = businessKey
? 'license'
: trialActive
? 'trial'
: transitionActive
? 'transition'
: null;
: paid
? 'license'
: null;
const business = businessKey || trialActive || transitionActive;

// A paid key never allows fewer users than Community; the evaluation and
// the transition period have no limit, whichever key is active.
let seatLimit: number | null = COMMUNITY_SEAT_LIMIT;
if (source === 'license') {
if (trialActive || transitionActive) {
seatLimit = null;
} else if (paid) {
const max = (license?.features as any)?.maxUsers;
seatLimit = typeof max === 'number' ? Math.max(COMMUNITY_SEAT_LIMIT, max) : null;
} else if (source) {
seatLimit = null;
}

return {
edition: source ? 'business' : 'community',
business: source !== null,
edition: paid ? (license!.plan as Edition) : business ? 'business' : 'community',
business,
source,
plan: license?.plan ?? null,
seatLimit,
seatsUsed,
communitySeatLimit: COMMUNITY_SEAT_LIMIT,
trialEndsAt: trialEnds?.toISOString() ?? null,
trialAvailable: trialRaw === null && !paid,
trialAvailable: trialRaw === null && !businessKey,
trialDays: BUSINESS_TRIAL_DAYS,
transitionUntil: transitionActive ? transitionUntil!.toISOString() : null,
};
Expand Down
4 changes: 2 additions & 2 deletions packages/frontend/src/app/settings/users/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -278,15 +278,15 @@ export default function SettingsUsersPage() {
</div>
)}

{seatsFull && !edition!.business && (
{seatsFull && edition!.edition === 'community' && (
<BusinessNotice
edition={edition!}
title={`Community includes ${edition!.seatLimit} users`}
body="Everyone here keeps working. To invite more people, move to AnythingMCP Business — it also adds single sign-on and SCIM."
/>
)}

{seatsFull && edition!.business && (
{seatsFull && edition!.edition !== 'community' && (
<div className="p-3 rounded-[9px] text-sm" style={{ background: 'var(--t-warn-bg)', color: 'var(--t-warn-fg)' }}>
Your license includes {edition!.seatLimit} users. To invite more people, change your plan under{' '}
<Link href="/settings/license" className="underline font-medium">Settings → License</Link>.
Expand Down
2 changes: 1 addition & 1 deletion packages/frontend/src/components/edition-banner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export function EditionBanner() {
} else if (edition.source === 'transition' && edition.transitionUntil) {
text = `Single sign-on, SCIM and more than ${edition.communitySeatLimit} users remain available on this instance until ${formatDay(edition.transitionUntil)}.`;
} else if (
!edition.business &&
edition.edition === 'community' &&
edition.seatLimit !== null &&
edition.seatsUsed >= edition.seatLimit
) {
Expand Down
8 changes: 3 additions & 5 deletions packages/frontend/src/components/edition-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,12 @@ export function EditionCard({ edition, onChange }: { edition: EditionState; onCh
return (
<Card className="p-5">
<div className="flex flex-wrap items-center gap-2 mb-1">
<h2 className="text-sm font-semibold text-[var(--text)]">
{edition.business ? 'Business' : 'Community'}
</h2>
<h2 className="text-sm font-semibold text-[var(--text)] capitalize">{edition.edition}</h2>
{source && <Badge tone={edition.source === 'license' ? 'success' : 'info'}>{source}</Badge>}
</div>
<p className="text-sm text-[var(--text-2)]">
{users}
{!edition.business && ` · Community includes up to ${edition.communitySeatLimit} users.`}
{edition.edition === 'community' && ` · Community includes up to ${edition.communitySeatLimit} users.`}
</p>

<div className="mt-4">
Expand Down Expand Up @@ -105,7 +103,7 @@ export function EditionCard({ edition, onChange }: { edition: EditionState; onCh

{error && <p className="mt-3 text-sm text-[var(--danger)]">{error}</p>}

{edition.source !== 'license' && (
{!(edition.source === 'license' && edition.business) && (
<div className="mt-4 pt-4 border-t border-[var(--border)] flex flex-wrap gap-3">
{edition.trialAvailable && (
<Button onClick={startTrial} disabled={starting}>
Expand Down
2 changes: 1 addition & 1 deletion packages/frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1164,7 +1164,7 @@ export const mcpKeys = {

/** GET /api/license/edition — see packages/backend/src/ee/licensing/edition.service.ts. */
export interface EditionState {
edition: 'cloud' | 'community' | 'business';
edition: 'cloud' | 'community' | 'starter' | 'team' | 'business' | 'enterprise';
business: boolean;
source: 'license' | 'trial' | 'transition' | null;
plan: string | null;
Expand Down
Loading