diff --git a/docs/license-faq.md b/docs/license-faq.md index 1167b4a4..105f7f13 100644 --- a/docs/license-faq.md +++ b/docs/license-faq.md @@ -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). --- diff --git a/packages/backend/src/ee/licensing/edition.service.spec.ts b/packages/backend/src/ee/licensing/edition.service.spec.ts index 9aa4e13f..518af7e6 100644 --- a/packages/backend/src/ee/licensing/edition.service.spec.ts +++ b/packages/backend/src/ee/licensing/edition.service.spec.ts @@ -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 () => { @@ -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 } }, diff --git a/packages/backend/src/ee/licensing/edition.service.ts b/packages/backend/src/ee/licensing/edition.service.ts index 28abaae2..f4f2d319 100644 --- a/packages/backend/src/ee/licensing/edition.service.ts +++ b/packages/backend/src/ee/licensing/edition.service.ts @@ -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. */ @@ -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, @@ -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, }; diff --git a/packages/frontend/src/app/settings/users/page.tsx b/packages/frontend/src/app/settings/users/page.tsx index 84a4dc65..e3314ea5 100644 --- a/packages/frontend/src/app/settings/users/page.tsx +++ b/packages/frontend/src/app/settings/users/page.tsx @@ -278,7 +278,7 @@ export default function SettingsUsersPage() { )} - {seatsFull && !edition!.business && ( + {seatsFull && edition!.edition === 'community' && ( )} - {seatsFull && edition!.business && ( + {seatsFull && edition!.edition !== 'community' && (
Your license includes {edition!.seatLimit} users. To invite more people, change your plan under{' '} Settings → License. diff --git a/packages/frontend/src/components/edition-banner.tsx b/packages/frontend/src/components/edition-banner.tsx index 43e4d767..6cc91f11 100644 --- a/packages/frontend/src/components/edition-banner.tsx +++ b/packages/frontend/src/components/edition-banner.tsx @@ -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 ) { diff --git a/packages/frontend/src/components/edition-card.tsx b/packages/frontend/src/components/edition-card.tsx index 0eed126f..1953df3e 100644 --- a/packages/frontend/src/components/edition-card.tsx +++ b/packages/frontend/src/components/edition-card.tsx @@ -57,14 +57,12 @@ export function EditionCard({ edition, onChange }: { edition: EditionState; onCh return (
-

- {edition.business ? 'Business' : 'Community'} -

+

{edition.edition}

{source && {source}}

{users} - {!edition.business && ` · Community includes up to ${edition.communitySeatLimit} users.`} + {edition.edition === 'community' && ` · Community includes up to ${edition.communitySeatLimit} users.`}

@@ -105,7 +103,7 @@ export function EditionCard({ edition, onChange }: { edition: EditionState; onCh {error &&

{error}

} - {edition.source !== 'license' && ( + {!(edition.source === 'license' && edition.business) && (
{edition.trialAvailable && (