From da9f2d1a3375280bab21b5fe71600171f9b97b6b Mon Sep 17 00:00:00 2001 From: Matteo Date: Mon, 28 Sep 2026 12:20:33 +0200 Subject: [PATCH] Add Community and Business editions for self-hosted instances Self-hosted instances run Community unless a license key is active. Community includes up to three active users; Business adds more users, single sign-on setup, SCIM provisioning and role sync. Administrators can start a 30-day Business trial on the instance, and instances that already use these capabilities keep them for 60 days after upgrading. Nothing that already works is taken away: existing users stay active, existing SSO identities keep signing in, an enforced SSO policy stays enforced and SCIM can always deactivate users. Cloud is unchanged. Also re-verifies the self-hosted license key periodically, and no longer shows the license wall on self-hosted instances. --- LICENSING.md | 5 +- docs/deployment.md | 2 +- docs/license-faq.md | 11 +- docs/scim-entra-setup.md | 3 +- docs/sso.md | 3 + packages/backend/src/app.module.ts | 2 + .../backend/src/auth/auth.controller.spec.ts | 1 + packages/backend/src/auth/auth.controller.ts | 13 +- .../src/auth/signup-attribution.spec.ts | 1 + packages/backend/src/ee/LICENSE | 10 +- packages/backend/src/ee/README.md | 14 +- .../ee/licensing/business-edition.guard.ts | 27 ++ .../src/ee/licensing/edition.controller.ts | 28 ++ .../src/ee/licensing/edition.service.spec.ts | 216 +++++++++++++++ .../src/ee/licensing/edition.service.ts | 247 ++++++++++++++++++ .../src/ee/licensing/licensing.module.ts | 17 ++ .../identity-providers.controller.spec.ts | 1 + .../identity-providers.controller.ts | 15 ++ .../scim/scim-users.service.spec.ts | 13 + .../scim/scim-users.service.ts | 4 +- .../scim/scim.controller.spec.ts | 55 ++++ .../scim/scim.controller.ts | 17 +- .../src/identity-providers/sso.controller.ts | 22 +- .../identity-providers/sso.service.spec.ts | 42 +++ .../src/identity-providers/sso.service.ts | 13 + .../backend/src/license/license.service.ts | 16 +- .../src/users/user-lifecycle.service.spec.ts | 13 + .../src/users/user-lifecycle.service.ts | 9 +- .../backend/src/users/users.controller.ts | 5 + packages/frontend/src/app/login/page.tsx | 43 ++- .../app/settings/identity-providers/page.tsx | 18 +- .../src/app/settings/license/page.tsx | 22 +- .../frontend/src/app/settings/users/page.tsx | 36 ++- .../frontend/src/components/app-shell.tsx | 2 + .../src/components/business-notice.tsx | 67 +++++ .../src/components/edition-banner.tsx | 64 +++++ .../frontend/src/components/edition-card.tsx | 130 +++++++++ .../frontend/src/components/license-wall.tsx | 11 +- .../src/components/onboarding-redirect.tsx | 8 +- .../frontend/src/components/usage-banner.tsx | 8 +- packages/frontend/src/lib/api.ts | 28 ++ packages/frontend/src/lib/marketing.ts | 10 +- packages/frontend/src/lib/use-edition.ts | 45 ++++ packages/frontend/src/lib/use-pricing-url.ts | 2 +- 44 files changed, 1253 insertions(+), 66 deletions(-) create mode 100644 packages/backend/src/ee/licensing/business-edition.guard.ts create mode 100644 packages/backend/src/ee/licensing/edition.controller.ts create mode 100644 packages/backend/src/ee/licensing/edition.service.spec.ts create mode 100644 packages/backend/src/ee/licensing/edition.service.ts create mode 100644 packages/backend/src/ee/licensing/licensing.module.ts create mode 100644 packages/backend/src/identity-providers/scim/scim.controller.spec.ts create mode 100644 packages/frontend/src/components/business-notice.tsx create mode 100644 packages/frontend/src/components/edition-banner.tsx create mode 100644 packages/frontend/src/components/edition-card.tsx create mode 100644 packages/frontend/src/lib/use-edition.ts diff --git a/LICENSING.md b/LICENSING.md index a98f0510..465c8a5a 100644 --- a/LICENSING.md +++ b/LICENSING.md @@ -16,9 +16,8 @@ All content that resides under any directory named `ee` (e.g. [`packages/backend/src/ee/`](packages/backend/src/ee/)) is **not** licensed under the AGPL. It is licensed under the AnythingMCP Commercial License — see the [LICENSE](packages/backend/src/ee/LICENSE) -file inside that directory. EE code contains operator-only -functionality for the AnythingMCP Cloud offering and is inert in -self-hosted deployments. +file inside that directory. EE code contains the AnythingMCP Cloud +operation and the licensing of the Business edition. ## Earlier releases diff --git a/docs/deployment.md b/docs/deployment.md index 0f917dcc..5b86bd38 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -350,7 +350,7 @@ curl -s http://localhost:4000/api/mcp-api-keys \ | `NEXTAUTH_SECRET` | No | NextAuth secret for frontend | | `FRONTEND_URL` | No | Frontend URL for email links (default: `http://localhost:3000`) | | `MCP_AUTH_MODE` | No | MCP auth: `none`, `legacy`, `oauth2`, `both` (default: `oauth2`) | -| `DEPLOYMENT_MODE` | No | `self-hosted` (default) or `cloud`. Anything other than `cloud` is self-hosted, so a community operator who never sets it keeps every self-hosted feature — including [single sign-on](sso.md) | +| `DEPLOYMENT_MODE` | No | `self-hosted` (default) or `cloud`. Anything other than `cloud` is self-hosted, so an operator who never sets it keeps every self-hosted feature — including [single sign-on](sso.md) | | `MCP_BEARER_TOKEN` | No | Bearer token for legacy MCP auth | | `MCP_API_KEY` | No | API key for legacy MCP auth | | `SERVER_URL` | No | Server URL for OAuth2 metadata (default: `http://localhost:4000`) | diff --git a/docs/license-faq.md b/docs/license-faq.md index 45e0654e..1167b4a4 100644 --- a/docs/license-faq.md +++ b/docs/license-faq.md @@ -10,7 +10,7 @@ AnythingMCP is licensed under the **GNU Affero General Public License v3** (AGPL-3.0-only), an OSI-approved **open-source** license. It is the same license used by Twenty, Cal.com, Grafana, Plausible and Mastodon. -The only exception is code under `ee/` directories (e.g. `packages/backend/src/ee/`), which contains operator-only functionality for the AnythingMCP Cloud offering and is licensed under the [AnythingMCP Commercial License](../packages/backend/src/ee/LICENSE). EE code is inert in self-hosted deployments — you don't need it. +The only exception is code under `ee/` directories (e.g. `packages/backend/src/ee/`), which contains the AnythingMCP Cloud operation and the licensing of the Business edition, and is licensed under the [AnythingMCP Commercial License](../packages/backend/src/ee/LICENSE). --- @@ -50,16 +50,21 @@ You can *use* AnythingMCP from a proprietary product over its API (your product ## What is the `ee/` directory? -Code under `ee/` directories powers the AnythingMCP Cloud operation (e.g. onboarding lifecycle emails). It is: +Code under `ee/` directories powers the AnythingMCP Cloud operation (e.g. onboarding lifecycle emails) and decides which edition a self-hosted instance runs. It is: - **Visible** — you can read and audit it like the rest of the repo - **Not AGPL** — it's under the AnythingMCP Commercial License -- **Not needed for self-hosting** — EE modules only load when `DEPLOYMENT_MODE=cloud` This split (AGPL core + commercial `ee/`) is the same model used by Cal.com and GitLab. --- +## Community 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). + +--- + ## What about old releases? Versions of AnythingMCP released **before** the AGPL adoption remain under the **Business Source License 1.1** they were published with. Those releases convert automatically to Apache 2.0 on their Change Date (2030-03-04). Everything from the AGPL adoption onward is AGPL-3.0-only. diff --git a/docs/scim-entra-setup.md b/docs/scim-entra-setup.md index e4045195..113a0970 100644 --- a/docs/scim-entra-setup.md +++ b/docs/scim-entra-setup.md @@ -7,7 +7,8 @@ handled, how roles behave between sign-ins — read mechanics. > **Self-hosted only.** Every SCIM route answers **404** on AnythingMCP Cloud. -> See [deployment.md](deployment.md) to run your own instance. +> See [deployment.md](deployment.md) to run your own instance. Provisioning +> new users needs AnythingMCP Business (see [the SSO guide](sso.md)). **Time:** about 15 minutes. **Reversible:** yes, at every step. diff --git a/docs/sso.md b/docs/sso.md index 09bda0d7..beb92a85 100644 --- a/docs/sso.md +++ b/docs/sso.md @@ -9,6 +9,9 @@ password, and keep their AnythingMCP roles in step with your directory groups. > in a shared, multi-tenant deployment it would let any customer point a > workspace at an arbitrary directory and provision accounts from it. Run your > own instance (Docker) to use it — see [deployment.md](deployment.md). +> +> Setting up SSO and SCIM needs **AnythingMCP Business**: a licence key, or +> the 30-day trial an administrator can start under **Settings → License**. --- diff --git a/packages/backend/src/app.module.ts b/packages/backend/src/app.module.ts index 30fd2c71..cec34d5d 100644 --- a/packages/backend/src/app.module.ts +++ b/packages/backend/src/app.module.ts @@ -39,6 +39,7 @@ import { EmailVerifiedGuard } from './auth/email-verified.guard'; import { AdaptersModule } from './adapters/adapters.module'; import { OrganizationsModule } from './organizations/organizations.module'; import { CloudModule } from './ee/cloud/cloud.module'; +import { LicensingModule } from './ee/licensing/licensing.module'; import { getRequiredSecret } from './common/secrets.util'; import { AppLoggerModule } from './common/logger.module'; import { SentryContextInterceptor } from './common/sentry-context.interceptor'; @@ -148,6 +149,7 @@ if (useOAuth) { KgModule, McpServersModule, LicenseModule, + LicensingModule, // Cloud-specific modules (conditionally loaded) ...cloudImports, diff --git a/packages/backend/src/auth/auth.controller.spec.ts b/packages/backend/src/auth/auth.controller.spec.ts index ce07b4f9..5c78a361 100644 --- a/packages/backend/src/auth/auth.controller.spec.ts +++ b/packages/backend/src/auth/auth.controller.spec.ts @@ -125,6 +125,7 @@ function makeController({ ssoEnforcement as any, // The real service, so the test sees what would actually be stored. new ProductEventService(prisma as any), + { assertSeatAvailable: jest.fn(async () => undefined), getState: jest.fn(async () => ({ trialAvailable: true })) } as any, // edition ); return { controller, users, sent, events, authService, usersService, emailService, prisma }; } diff --git a/packages/backend/src/auth/auth.controller.ts b/packages/backend/src/auth/auth.controller.ts index 512d66bf..0c082584 100644 --- a/packages/backend/src/auth/auth.controller.ts +++ b/packages/backend/src/auth/auth.controller.ts @@ -39,6 +39,7 @@ import { RecoveryCodesService } from './recovery-codes.service'; import { SsoEnforcementService } from './sso-enforcement.service'; import { Roles, RolesGuard } from './roles.guard'; import { SelfHostedOnlyGuard } from '../common/self-hosted-only.guard'; +import { EditionService } from '../ee/licensing/edition.service'; import { SignupAttributionDto } from './signup-attribution.dto'; /** @@ -223,6 +224,7 @@ export class AuthController { private readonly recoveryCodes: RecoveryCodesService, private readonly ssoEnforcement: SsoEnforcementService, private readonly productEvents: ProductEventService, + private readonly edition: EditionService, ) {} private getFrontendUrl(_req?: any): string { @@ -398,7 +400,10 @@ export class AuthController { let needsLicenseSetup = false; if (user.role === 'ADMIN') { const licenseKey = await this.siteSettings.get('license_key'); - if (!licenseKey) { + // Self-hosted: once the Business trial has been started the choice has + // been made, and the chooser would only offer a trial that cannot start. + const isCloud = this.configService.get('DEPLOYMENT_MODE') === 'cloud'; + if (!licenseKey && (isCloud || (await this.edition.getState()).trialAvailable)) { needsLicenseSetup = true; } } @@ -445,6 +450,7 @@ export class AuthController { if (existing) { throw new ConflictException('Email already registered'); } + if (userCount > 0) await this.edition.assertSeatAvailable(); // Self-hosted: first user is ADMIN of a new organization, later ones // join the existing (first) organization as EDITOR. @@ -721,6 +727,9 @@ export class AuthController { } // User exists but not in this org — allow invitation for multi-org membership } + // Refused here too, not only on acceptance, so the admin learns it before + // the invitee does. + await this.edition.assertSeatAvailable(existing?.id); // Reuse an existing pending invitation instead of rejecting: throwing a // 409 here left admins stuck with no way to get the link after a failed @@ -852,11 +861,13 @@ export class AuthController { : 'You are already a member of this organization', ); } + await this.edition.assertSeatAvailable(existing.id); await this.organizationsService.addMember(existing.id, invite.organizationId, invite.role); // Switch their active org to the newly joined one user = await this.organizationsService.switchOrg(existing.id, invite.organizationId); } else { // New user — create account and join the organization + await this.edition.assertSeatAvailable(); const passwordHash = await this.authService.hashPassword(dto.password); user = await this.usersService.create({ email: invite.email, diff --git a/packages/backend/src/auth/signup-attribution.spec.ts b/packages/backend/src/auth/signup-attribution.spec.ts index 9924a4fc..d47bd7f9 100644 --- a/packages/backend/src/auth/signup-attribution.spec.ts +++ b/packages/backend/src/auth/signup-attribution.spec.ts @@ -176,6 +176,7 @@ function makeController(mode: 'cloud' | 'self-hosted', existingEmails: string[] {} as any, {} as any, new ProductEventService(prisma as any), + { assertSeatAvailable: jest.fn(async () => undefined), getState: jest.fn(async () => ({ trialAvailable: true })) } as any, // edition ); return { controller, events, usersService }; } diff --git a/packages/backend/src/ee/LICENSE b/packages/backend/src/ee/LICENSE index 0eef5a99..4df047eb 100644 --- a/packages/backend/src/ee/LICENSE +++ b/packages/backend/src/ee/LICENSE @@ -13,13 +13,15 @@ Without a Commercial Agreement you may: 1. View and audit the EE Code. 2. Modify the EE Code solely for the purpose of contributing changes back to helpcode.ai GmbH. - 3. Build and run the software with the EE Code disabled (the default - for self-hosted deployments — EE modules are only loaded when - DEPLOYMENT_MODE=cloud or an equivalent operator flag is set). + 3. Build and run the software with the EE Code's commercial + functionality inactive, which is the default for self-hosted + deployments without a licence key. Without a Commercial Agreement you may NOT: - 1. Run the EE Code in production. + 1. Use the EE Code's commercial functionality in production without + a valid licence key or Commercial Agreement, other than during the + built-in trial or the transition period of an upgraded instance. 2. Redistribute the EE Code, modified or unmodified, as part of any product or service. 3. Remove, bypass, or alter any license-checking or feature-gating diff --git a/packages/backend/src/ee/README.md b/packages/backend/src/ee/README.md index 74c3d380..2a3cc81b 100644 --- a/packages/backend/src/ee/README.md +++ b/packages/backend/src/ee/README.md @@ -4,11 +4,11 @@ Everything under this directory is licensed under the [AnythingMCP Commercial License](LICENSE), **not** the AGPL that covers the rest of the repository. -EE code contains operator-only functionality used by the AnythingMCP -Cloud offering (e.g. onboarding lifecycle emails). It is inert in -self-hosted deployments: EE modules are only loaded when -`DEPLOYMENT_MODE=cloud`, and a community deployment works fully -without them. +- `cloud/` — operator-only functionality of AnythingMCP Cloud (e.g. + onboarding lifecycle emails). Loaded only when `DEPLOYMENT_MODE=cloud`. +- `licensing/` — which edition a self-hosted instance runs (Community + or Business) and what each allows. Loaded everywhere; Business + capabilities are active only with a licence key, during the trial, or + during the transition period of an upgraded instance. -If you are self-hosting, you don't need anything in here. If you want -to use EE features commercially, contact info@helpcode.ai. +For licensing questions, contact info@helpcode.ai. diff --git a/packages/backend/src/ee/licensing/business-edition.guard.ts b/packages/backend/src/ee/licensing/business-edition.guard.ts new file mode 100644 index 00000000..2372c5f3 --- /dev/null +++ b/packages/backend/src/ee/licensing/business-edition.guard.ts @@ -0,0 +1,27 @@ +import { CanActivate, ExecutionContext, Injectable, SetMetadata } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { EditionService } from './edition.service'; + +const CAPABILITY_KEY = 'business_capability'; + +/** Names the capability in the 403 message, e.g. `@BusinessCapability('Single sign-on')`. */ +export const BusinessCapability = (name: string) => SetMetadata(CAPABILITY_KEY, name); + +/** Lets a route through only when the instance has Business capabilities. */ +@Injectable() +export class BusinessEditionGuard implements CanActivate { + constructor( + private readonly edition: EditionService, + private readonly reflector: Reflector, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const capability = + this.reflector.getAllAndOverride(CAPABILITY_KEY, [ + context.getHandler(), + context.getClass(), + ]) ?? 'This feature'; + await this.edition.assertBusiness(capability); + return true; + } +} diff --git a/packages/backend/src/ee/licensing/edition.controller.ts b/packages/backend/src/ee/licensing/edition.controller.ts new file mode 100644 index 00000000..e38dccbc --- /dev/null +++ b/packages/backend/src/ee/licensing/edition.controller.ts @@ -0,0 +1,28 @@ +import { Controller, Get, HttpCode, HttpStatus, Post, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { AuthGuard } from '@nestjs/passport'; +import { Roles, RolesGuard } from '../../auth/roles.guard'; +import { EditionService } from './edition.service'; + +@ApiTags('License') +@ApiBearerAuth() +@UseGuards(AuthGuard('jwt')) +@Controller('api/license') +export class EditionController { + constructor(private readonly edition: EditionService) {} + + @Get('edition') + @ApiOperation({ summary: 'Edition of this instance, its user limit and Business availability' }) + getEdition() { + return this.edition.getState(); + } + + @Post('business-trial') + @HttpCode(HttpStatus.OK) + @UseGuards(RolesGuard) + @Roles('ADMIN') + @ApiOperation({ summary: 'Start the one-time Business trial on a self-hosted instance (ADMIN)' }) + startTrial() { + return this.edition.startTrial(); + } +} diff --git a/packages/backend/src/ee/licensing/edition.service.spec.ts b/packages/backend/src/ee/licensing/edition.service.spec.ts new file mode 100644 index 00000000..9aa4e13f --- /dev/null +++ b/packages/backend/src/ee/licensing/edition.service.spec.ts @@ -0,0 +1,216 @@ +import { ConflictException, ForbiddenException, NotFoundException } from '@nestjs/common'; +import { + BUSINESS_TRIAL_DAYS, + COMMUNITY_SEAT_LIMIT, + EditionService, + TRANSITION_DAYS, +} from './edition.service'; + +const DAY = 24 * 60 * 60 * 1000; + +function make(opts: { + cloud?: boolean; + license?: any; + settings?: Record; + seats?: number; + providers?: number; + activeMemberships?: number; +}) { + const settings: Record = { ...(opts.settings ?? {}) }; + const prisma = { + user: { count: jest.fn(async () => opts.seats ?? 1) }, + identityProvider: { count: jest.fn(async () => opts.providers ?? 0) }, + organizationMember: { count: jest.fn(async () => opts.activeMemberships ?? 0) }, + }; + const siteSettings = { + get: jest.fn(async (k: string) => (k in settings ? settings[k] : null)), + set: jest.fn(async (k: string, v: string) => { + settings[k] = v; + }), + }; + const licenses = { getCurrentLicense: jest.fn(async () => opts.license ?? null) }; + const deployment = { isCloud: () => !!opts.cloud }; + const service = new EditionService( + prisma as any, + deployment as any, + siteSettings as any, + licenses as any, + ); + return { service, settings, prisma }; +} + +describe('EditionService', () => { + describe('getState', () => { + it('is Community with the Community user limit by default', async () => { + const { service } = make({ seats: 2 }); + const s = await service.getState(); + expect(s).toMatchObject({ + edition: 'community', + business: false, + source: null, + seatLimit: COMMUNITY_SEAT_LIMIT, + seatsUsed: 2, + trialAvailable: true, + }); + }); + + it('treats a community licence key as Community', async () => { + const { service } = make({ license: { plan: 'community', status: 'active', expiresAt: null } }); + expect((await service.getState()).edition).toBe('community'); + }); + + it('is Business with an active paid licence, using its user count when higher', async () => { + const { service } = make({ + 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 }); + }); + + it('never gives a paid licence fewer users than Community', async () => { + const { service } = make({ + license: { plan: 'starter', status: 'active', expiresAt: null, features: { maxUsers: 1 } }, + }); + expect((await service.getState()).seatLimit).toBe(COMMUNITY_SEAT_LIMIT); + }); + + it('has no user limit when the licence carries none', async () => { + const { service } = make({ + license: { plan: 'enterprise', status: 'active', expiresAt: null, features: { maxUsers: null } }, + }); + expect((await service.getState()).seatLimit).toBeNull(); + }); + + it('falls back to Community when the licence is expired, revoked or past its date', async () => { + for (const license of [ + { plan: 'business', status: 'expired', expiresAt: null }, + { plan: 'business', status: 'revoked', expiresAt: null }, + { plan: 'team', status: 'active', expiresAt: new Date(Date.now() - DAY) }, + ]) { + const { service } = make({ license }); + expect((await service.getState()).edition).toBe('community'); + } + }); + + it('is Business without a user limit during the trial, and Community after it', async () => { + const now = new Date(); + const running = make({ settings: { business_trial_ends_at: new Date(now.getTime() + DAY).toISOString() } }); + expect(await running.service.getState(now)).toMatchObject({ + edition: 'business', + source: 'trial', + seatLimit: null, + trialAvailable: false, + }); + + const over = make({ settings: { business_trial_ends_at: new Date(now.getTime() - DAY).toISOString() } }); + expect(await over.service.getState(now)).toMatchObject({ + edition: 'community', + trialAvailable: false, + trialEndsAt: expect.any(String), + }); + }); + + it('is Business during the transition period, and reports its end', async () => { + const until = new Date(Date.now() + 10 * DAY).toISOString(); + const { service } = make({ settings: { business_transition_until: until } }); + expect(await service.getState()).toMatchObject({ source: 'transition', transitionUntil: until }); + }); + + it('is never limited in cloud', async () => { + const { service } = make({ cloud: true, seats: 50 }); + expect(await service.getState()).toMatchObject({ edition: 'cloud', business: true, seatLimit: null }); + }); + }); + + describe('seats', () => { + it('refuses a new user once the limit is reached, with a recognisable code', async () => { + const { service } = make({ seats: COMMUNITY_SEAT_LIMIT }); + await expect(service.assertSeatAvailable()).rejects.toBeInstanceOf(ForbiddenException); + await expect(service.assertSeatAvailable()).rejects.toMatchObject({ + response: expect.objectContaining({ code: 'seat_limit' }), + }); + }); + + it('lets a new user in below the limit', async () => { + const { service } = make({ seats: COMMUNITY_SEAT_LIMIT - 1 }); + await expect(service.assertSeatAvailable()).resolves.toBeUndefined(); + }); + + it('does not count a user who is already active elsewhere on the instance', async () => { + const { service } = make({ seats: 10, activeMemberships: 1 }); + await expect(service.assertSeatAvailable('u1')).resolves.toBeUndefined(); + }); + + it('counts a user whose memberships are all deactivated', async () => { + const { service } = make({ seats: COMMUNITY_SEAT_LIMIT, activeMemberships: 0 }); + await expect(service.assertSeatAvailable('u1')).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('never limits cloud', async () => { + const { service } = make({ cloud: true, seats: 500 }); + await expect(service.assertSeatAvailable()).resolves.toBeUndefined(); + }); + }); + + describe('assertBusiness', () => { + it('names the capability in the refusal', async () => { + const { service } = make({}); + await expect(service.assertBusiness('Single sign-on')).rejects.toMatchObject({ + response: expect.objectContaining({ + code: 'edition_required', + message: expect.stringContaining('Single sign-on'), + }), + }); + }); + + it('passes with Business', async () => { + const { service } = make({ license: { plan: 'business', status: 'active', expiresAt: null } }); + await expect(service.assertBusiness('Single sign-on')).resolves.toBeUndefined(); + }); + }); + + describe('startTrial', () => { + it('starts once, for BUSINESS_TRIAL_DAYS', async () => { + const now = new Date('2026-10-01T00:00:00Z'); + const { service, settings } = make({}); + const s = await service.startTrial(now); + expect(settings.business_trial_ends_at).toBe(new Date(now.getTime() + BUSINESS_TRIAL_DAYS * DAY).toISOString()); + expect(s).toMatchObject({ edition: 'business', source: 'trial' }); + await expect(service.startTrial(now)).rejects.toBeInstanceOf(ConflictException); + }); + + it('does not exist in cloud', async () => { + const { service } = make({ cloud: true }); + await expect(service.startTrial()).rejects.toBeInstanceOf(NotFoundException); + }); + }); + + describe('recordTransition', () => { + const now = new Date('2026-10-01T00:00:00Z'); + + it('keeps Business for an instance that already has an identity provider', async () => { + const { service, settings } = make({ providers: 1 }); + await service.recordTransition(now); + expect(settings.business_transition_until).toBe(new Date(now.getTime() + TRANSITION_DAYS * DAY).toISOString()); + }); + + it('keeps Business for an instance with more users than Community includes', async () => { + const { service, settings } = make({ seats: COMMUNITY_SEAT_LIMIT + 1 }); + await service.recordTransition(now); + expect(settings.business_transition_until).not.toBe('none'); + }); + + it('records "none" for any other instance, including a fresh one', async () => { + const { service, settings } = make({ seats: 0 }); + await service.recordTransition(now); + expect(settings.business_transition_until).toBe('none'); + }); + + it('runs only once', async () => { + const { service, settings, prisma } = make({ settings: { business_transition_until: 'none' }, providers: 3 }); + await service.recordTransition(now); + expect(settings.business_transition_until).toBe('none'); + expect(prisma.identityProvider.count).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/backend/src/ee/licensing/edition.service.ts b/packages/backend/src/ee/licensing/edition.service.ts new file mode 100644 index 00000000..28abaae2 --- /dev/null +++ b/packages/backend/src/ee/licensing/edition.service.ts @@ -0,0 +1,247 @@ +import { + ConflictException, + ForbiddenException, + Injectable, + Logger, + NotFoundException, + OnModuleInit, +} from '@nestjs/common'; +import { PrismaService } from '../../common/prisma.service'; +import { DeploymentService } from '../../common/deployment.service'; +import { SiteSettingsService } from '../../settings/site-settings.service'; +import { LicenseService, LicenseInfo } from '../../license/license.service'; + +/** Active users a Community instance includes. */ +export const COMMUNITY_SEAT_LIMIT = 3; +/** Length of the Business evaluation an admin can start once per instance. */ +export const BUSINESS_TRIAL_DAYS = 30; +/** + * How long an instance that already used Business capabilities before this + * release keeps them, counted from its first start on this release. + */ +export const TRANSITION_DAYS = 60; + +const PAID_PLANS = new Set(['starter', 'team', '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 BusinessSource = 'license' | 'trial' | 'transition' | null; + +export interface EditionState { + edition: Edition; + /** Whether Business capabilities (SSO, SCIM, more users) are available. */ + business: boolean; + source: BusinessSource; + /** Plan of the activated licence key, if any. */ + plan: string | null; + /** Active users this instance may have; null = no limit. */ + seatLimit: number | null; + seatsUsed: number; + communitySeatLimit: number; + trialEndsAt: string | null; + trialAvailable: boolean; + trialDays: number; + transitionUntil: string | null; +} + +/** Error code the frontend reads to offer the Business options. */ +export const EDITION_REQUIRED = 'edition_required'; +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. + * + * The rules never take away something that already works: existing users stay + * active above the limit, existing single sign-on identities keep signing in, + * an enforced SSO policy stays enforced and SCIM keeps deactivating leavers. + * What needs Business is setting those capabilities up and adding people. + * + * AnythingMCP Cloud has its own plans and is never limited here. + */ +@Injectable() +export class EditionService implements OnModuleInit { + private readonly logger = new Logger(EditionService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly deployment: DeploymentService, + private readonly siteSettings: SiteSettingsService, + private readonly licenses: LicenseService, + ) {} + + async onModuleInit(): Promise { + if (this.deployment.isCloud()) return; + try { + await this.recordTransition(); + } catch (err: any) { + this.logger.warn(`Could not evaluate the edition transition: ${err.message}`); + } + } + + /** + * Runs once per instance. An instance that already has an identity provider + * or more users than Community includes keeps Business capabilities for + * TRANSITION_DAYS, so upgrading never changes what it can do overnight. + */ + async recordTransition(now = new Date()): Promise { + if ((await this.siteSettings.get(TRANSITION_KEY)) !== null) return; + + const [providers, seats] = await Promise.all([ + this.prisma.identityProvider.count(), + this.countSeats(), + ]); + if (providers > 0 || seats > COMMUNITY_SEAT_LIMIT) { + const until = new Date(now.getTime() + TRANSITION_DAYS * DAY_MS); + await this.siteSettings.set(TRANSITION_KEY, until.toISOString()); + this.logger.log(`Business capabilities kept until ${until.toISOString()}.`); + } else { + await this.siteSettings.set(TRANSITION_KEY, 'none'); + } + } + + async getState(now = new Date()): Promise { + const seatsUsed = await this.countSeats(); + + if (this.deployment.isCloud()) { + return { + edition: 'cloud', + business: true, + source: null, + plan: null, + seatLimit: null, + seatsUsed, + communitySeatLimit: COMMUNITY_SEAT_LIMIT, + trialEndsAt: null, + trialAvailable: false, + trialDays: BUSINESS_TRIAL_DAYS, + transitionUntil: null, + }; + } + + const [license, trialRaw, transitionRaw] = await Promise.all([ + this.licenses.getCurrentLicense(), + this.siteSettings.get(TRIAL_KEY), + this.siteSettings.get(TRANSITION_KEY), + ]); + + const paid = this.isPaid(license, now); + const trialEnds = parseDate(trialRaw); + const transitionUntil = parseDate(transitionRaw); + const trialActive = !!trialEnds && trialEnds > now; + const transitionActive = !!transitionUntil && transitionUntil > now; + + const source: BusinessSource = paid + ? 'license' + : trialActive + ? 'trial' + : transitionActive + ? 'transition' + : null; + + let seatLimit: number | null = COMMUNITY_SEAT_LIMIT; + if (source === 'license') { + 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, + source, + plan: license?.plan ?? null, + seatLimit, + seatsUsed, + communitySeatLimit: COMMUNITY_SEAT_LIMIT, + trialEndsAt: trialEnds?.toISOString() ?? null, + trialAvailable: trialRaw === null && !paid, + trialDays: BUSINESS_TRIAL_DAYS, + transitionUntil: transitionActive ? transitionUntil!.toISOString() : null, + }; + } + + async hasBusiness(): Promise { + if (this.deployment.isCloud()) return true; + return (await this.getState()).business; + } + + /** Throws a 403 the frontend recognises when Business is not available. */ + async assertBusiness(capability: string): Promise { + if (await this.hasBusiness()) return; + throw new ForbiddenException({ + statusCode: 403, + code: EDITION_REQUIRED, + message: `${capability} is available with AnythingMCP Business. Start a trial or activate a license key under Settings → License.`, + }); + } + + /** + * Whether one more active user fits. A user who is already active in some + * workspace of this instance does not take another seat. + */ + async seatAvailable(userId?: string): Promise<{ ok: boolean; limit: number | null }> { + if (this.deployment.isCloud()) return { ok: true, limit: null }; + if (userId && (await this.isActiveSomewhere(userId))) return { ok: true, limit: null }; + const state = await this.getState(); + if (state.seatLimit === null) return { ok: true, limit: null }; + return { ok: state.seatsUsed < state.seatLimit, limit: state.seatLimit }; + } + + async assertSeatAvailable(userId?: string): Promise { + const { ok, limit } = await this.seatAvailable(userId); + if (ok) return; + throw new ForbiddenException({ + statusCode: 403, + code: SEAT_LIMIT, + message: seatLimitMessage(limit!), + }); + } + + async startTrial(now = new Date()): Promise { + if (this.deployment.isCloud()) throw new NotFoundException('Not found'); + if ((await this.siteSettings.get(TRIAL_KEY)) !== null) { + throw new ConflictException('The Business trial has already been used on this instance.'); + } + const ends = new Date(now.getTime() + BUSINESS_TRIAL_DAYS * DAY_MS); + await this.siteSettings.set(TRIAL_KEY, ends.toISOString()); + this.logger.log(`Business trial started, ends ${ends.toISOString()}.`); + return this.getState(now); + } + + private isPaid(license: LicenseInfo | null, now: Date): boolean { + if (!license || !PAID_PLANS.has(license.plan)) return false; + if (license.status !== 'active') return false; + return !license.expiresAt || new Date(license.expiresAt) > now; + } + + /** Users with at least one active membership, across the instance. */ + private countSeats(): Promise { + return this.prisma.user.count({ + where: { memberships: { some: { deactivatedAt: null } } }, + }); + } + + private async isActiveSomewhere(userId: string): Promise { + const n = await this.prisma.organizationMember.count({ + where: { userId, deactivatedAt: null }, + }); + return n > 0; + } +} + +export function seatLimitMessage(limit: number): string { + return `This instance has reached its limit of ${limit} active users. An administrator can add more with AnythingMCP Business under Settings → License.`; +} + +function parseDate(value: string | null): Date | null { + if (!value || value === 'none') return null; + const d = new Date(value); + return Number.isNaN(d.getTime()) ? null : d; +} diff --git a/packages/backend/src/ee/licensing/licensing.module.ts b/packages/backend/src/ee/licensing/licensing.module.ts new file mode 100644 index 00000000..7f54168b --- /dev/null +++ b/packages/backend/src/ee/licensing/licensing.module.ts @@ -0,0 +1,17 @@ +import { Global, Module } from '@nestjs/common'; +import { LicenseModule } from '../../license/license.module'; +import { SettingsModule } from '../../settings/settings.module'; +import { EditionService } from './edition.service'; +import { EditionController } from './edition.controller'; +import { BusinessEditionGuard } from './business-edition.guard'; + +// Global: the user, SSO and SCIM modules consult the edition, and LicenseModule +// already imports UsersModule, so an explicit import there would be circular. +@Global() +@Module({ + imports: [LicenseModule, SettingsModule], + controllers: [EditionController], + providers: [EditionService, BusinessEditionGuard], + exports: [EditionService, BusinessEditionGuard], +}) +export class LicensingModule {} diff --git a/packages/backend/src/identity-providers/identity-providers.controller.spec.ts b/packages/backend/src/identity-providers/identity-providers.controller.spec.ts index f5be6534..077959a1 100644 --- a/packages/backend/src/identity-providers/identity-providers.controller.spec.ts +++ b/packages/backend/src/identity-providers/identity-providers.controller.spec.ts @@ -56,6 +56,7 @@ describe('IdentityProvidersController audit trail', () => { new SecurityEventService(prisma as unknown as PrismaService), { hasUnused: jest.fn(async () => true) } as any, { resyncProvider: jest.fn(async () => ({ total: 0 })) } as any, + { assertBusiness: jest.fn(async () => undefined) } as any, ); }); diff --git a/packages/backend/src/identity-providers/identity-providers.controller.ts b/packages/backend/src/identity-providers/identity-providers.controller.ts index 20ac65af..db199bda 100644 --- a/packages/backend/src/identity-providers/identity-providers.controller.ts +++ b/packages/backend/src/identity-providers/identity-providers.controller.ts @@ -34,6 +34,8 @@ import { Type } from 'class-transformer'; import { IdentityProviderType } from '../generated/prisma/client'; import { Roles, RolesGuard } from '../auth/roles.guard'; import { SelfHostedOnlyGuard } from '../common/self-hosted-only.guard'; +import { BusinessCapability, BusinessEditionGuard } from '../ee/licensing/business-edition.guard'; +import { EditionService } from '../ee/licensing/edition.service'; import { IdentityProvidersService, IdentityProviderError, @@ -209,6 +211,7 @@ export class IdentityProvidersController { private readonly securityEvents: SecurityEventService, private readonly recoveryCodes: RecoveryCodesService, private readonly roleSync: RoleSyncService, + private readonly edition: EditionService, ) {} @Get() @@ -230,6 +233,8 @@ export class IdentityProvidersController { @Post() @ApiOperation({ summary: 'Create an identity provider (ADMIN)' }) + @UseGuards(BusinessEditionGuard) + @BusinessCapability('Single sign-on') async create(@Req() req: any, @Body() dto: UpsertProviderDto) { const created = await this.run(() => this.service.create(req.user.organizationId, dto), @@ -245,6 +250,8 @@ export class IdentityProvidersController { @Put(':id') @ApiOperation({ summary: 'Update an identity provider (ADMIN)' }) + @UseGuards(BusinessEditionGuard) + @BusinessCapability('Single sign-on') async update( @Req() req: any, @Param('id') id: string, @@ -333,6 +340,8 @@ export class IdentityProvidersController { } @Put(':id/role-mappings') + @UseGuards(BusinessEditionGuard) + @BusinessCapability('Role sync from directory groups') @ApiOperation({ summary: 'Replace this provider\'s group/app-role mappings (ADMIN)', }) @@ -374,6 +383,8 @@ export class IdentityProvidersController { @Param('id') id: string, @Body() dto: EnforceSsoDto, ) { + // Turning the policy off is always allowed. + if (dto.enforce) await this.edition.assertBusiness('Requiring single sign-on'); const hasRecoveryCodes = await this.recoveryCodes.hasUnused(req.user.sub); const updated = await this.run(() => this.service.setEnforceSso(id, req.user.organizationId, dto.enforce, { @@ -390,6 +401,8 @@ export class IdentityProvidersController { } @Post(':id/resync-roles') + @UseGuards(BusinessEditionGuard) + @BusinessCapability('Role sync from directory groups') @ApiOperation({ summary: 'Re-derive every SCIM-managed member\'s roles from stored group membership (ADMIN)' }) async resyncRoles(@Req() req: any, @Param('id') id: string) { const provider = await this.service.findByIdForOrg(id, req.user.organizationId); @@ -412,6 +425,8 @@ export class IdentityProvidersController { @Put(':id/scim') @ApiOperation({ summary: 'Enable or disable SCIM provisioning (ADMIN). First enable returns the bearer token once.' }) async setScim(@Req() req: any, @Param('id') id: string, @Body() dto: ScimSettingsDto) { + // Disabling is always allowed. + if (dto.enabled) await this.edition.assertBusiness('SCIM provisioning'); const result = await this.run(() => this.service.setScimEnabled(id, req.user.organizationId, dto.enabled, this.publicBaseUrl(req)), ); diff --git a/packages/backend/src/identity-providers/scim/scim-users.service.spec.ts b/packages/backend/src/identity-providers/scim/scim-users.service.spec.ts index a50a354a..06d3d5be 100644 --- a/packages/backend/src/identity-providers/scim/scim-users.service.spec.ts +++ b/packages/backend/src/identity-providers/scim/scim-users.service.spec.ts @@ -170,6 +170,19 @@ describe('ScimUsersService', () => { expect(eventNames()).toEqual(['SCIM_USER_REACTIVATED']); }); + it('active:true answers 403 when the instance has no free seat, and changes nothing', async () => { + lifecycle.reactivateInOrganization.mockResolvedValueOnce({ status: 'seat_limit', limit: 3 }); + prisma.userIdentity.findUnique.mockResolvedValueOnce( + identity({}, { memberships: [{ organizationId: ORG, deactivatedAt: new Date() }] }), + ); + const err = await service + .patch(provider, 'u1', patch([{ op: 'replace', value: { active: true } }]), ctx) + .catch((e) => e); + expect(err.getStatus()).toBe(403); + expect(roleSync.syncFromScim).not.toHaveBeenCalled(); + expect(eventNames()).toEqual([]); + }); + // The membership is the workspace's one way back in; Entra is told loudly. it('surfaces the last-admin outcome as a 409 after revoking sessions and keys', async () => { lifecycle.deactivateInOrganization.mockResolvedValue({ status: 'last_admin_retained', keysDeactivated: 2 }); diff --git a/packages/backend/src/identity-providers/scim/scim-users.service.ts b/packages/backend/src/identity-providers/scim/scim-users.service.ts index 0e4ecb79..05096daf 100644 --- a/packages/backend/src/identity-providers/scim/scim-users.service.ts +++ b/packages/backend/src/identity-providers/scim/scim-users.service.ts @@ -6,6 +6,7 @@ import { } from '../../audit/security-event.service'; import { UserLifecycleService } from '../../users/user-lifecycle.service'; import { RoleSyncService } from '../role-sync.service'; +import { seatLimitMessage } from '../../ee/licensing/edition.service'; import { ScimError } from './scim.errors'; import { coerceActive, @@ -409,7 +410,8 @@ export class ScimUsersService { } if (changes.active === true && membership && !isActive) { - await this.lifecycle.reactivateInOrganization(row.userId, orgId, this.lifecycleCtx(provider, ctx)); + const result = await this.lifecycle.reactivateInOrganization(row.userId, orgId, this.lifecycleCtx(provider, ctx)); + if (result.status === 'seat_limit') throw new ScimError(403, seatLimitMessage(result.limit)); await this.securityEvents.log({ event: SecurityEvents.SCIM_USER_REACTIVATED, actorType: 'SYSTEM', diff --git a/packages/backend/src/identity-providers/scim/scim.controller.spec.ts b/packages/backend/src/identity-providers/scim/scim.controller.spec.ts new file mode 100644 index 00000000..d0d0d9b7 --- /dev/null +++ b/packages/backend/src/identity-providers/scim/scim.controller.spec.ts @@ -0,0 +1,55 @@ +import { ScimController } from './scim.controller'; +import { ScimError } from './scim.errors'; + +describe('ScimController — provisioning and the edition', () => { + const req = { scimProvider: { id: 'p1', organizationId: 'o1' }, headers: {}, ip: '127.0.0.1', protocol: 'https' } as any; + + function make(edition: { business: boolean; seat: { ok: boolean; limit: number | null } }) { + const users = { create: jest.fn(async () => ({ id: 'u' })), patch: jest.fn(async () => ({})), remove: jest.fn(async () => undefined) }; + const groups = { create: jest.fn(async () => ({ id: 'g' })) }; + const config = { get: () => 'https://amcp.example' }; + const ed = { + hasBusiness: jest.fn(async () => edition.business), + seatAvailable: jest.fn(async () => edition.seat), + }; + const controller = new ScimController(users as any, groups as any, config as any, ed as any); + return { controller, users, groups }; + } + + it('creates a user with Business and a free seat', async () => { + const { controller, users } = make({ business: true, seat: { ok: true, limit: null } }); + await controller.createUser(req, {}); + expect(users.create).toHaveBeenCalled(); + }); + + it('refuses to create a user without Business, as a SCIM 403', async () => { + const { controller, users } = make({ business: false, seat: { ok: true, limit: 3 } }); + const err = await controller.createUser(req, {}).catch((e) => e); + expect(err).toBeInstanceOf(ScimError); + expect(err.getStatus()).toBe(403); + expect(users.create).not.toHaveBeenCalled(); + }); + + it('refuses to create a user when no seat is free', async () => { + const { controller, users } = make({ business: true, seat: { ok: false, limit: 10 } }); + const err = await controller.createUser(req, {}).catch((e) => e); + expect(err.getStatus()).toBe(403); + expect(err.getResponse().detail).toContain('10 active users'); + expect(users.create).not.toHaveBeenCalled(); + }); + + it('refuses to create a group without Business', async () => { + const { controller, groups } = make({ business: false, seat: { ok: true, limit: 3 } }); + await expect(controller.createGroup(req, {})).rejects.toBeInstanceOf(ScimError); + expect(groups.create).not.toHaveBeenCalled(); + }); + + // A directory must always be able to take a leaver's access away. + it('keeps updates and deletions working without Business', async () => { + const { controller, users } = make({ business: false, seat: { ok: false, limit: 3 } }); + await controller.patchUser(req, 'u1', { Operations: [] }); + await controller.deleteUser(req, 'u1'); + expect(users.patch).toHaveBeenCalled(); + expect(users.remove).toHaveBeenCalled(); + }); +}); diff --git a/packages/backend/src/identity-providers/scim/scim.controller.ts b/packages/backend/src/identity-providers/scim/scim.controller.ts index 18f8b8f8..a14e762c 100644 --- a/packages/backend/src/identity-providers/scim/scim.controller.ts +++ b/packages/backend/src/identity-providers/scim/scim.controller.ts @@ -20,6 +20,7 @@ import { Throttle } from '@nestjs/throttler'; import { ConfigService } from '@nestjs/config'; import { Request } from 'express'; import { SelfHostedOnlyGuard } from '../../common/self-hosted-only.guard'; +import { EditionService, seatLimitMessage } from '../../ee/licensing/edition.service'; import { ScimAuthGuard, ScimProvider } from './scim-auth.guard'; import { SCIM_CONTENT_TYPE, ScimError, ScimExceptionFilter } from './scim.errors'; import { parseExcluded, parseFilter, parsePagination } from './scim.parser'; @@ -59,6 +60,7 @@ export class ScimController { private readonly users: ScimUsersService, private readonly groups: ScimGroupsService, private readonly config: ConfigService, + private readonly edition: EditionService, ) {} // ── Discovery ───────────────────────────────────────────────────────────── @@ -114,7 +116,15 @@ export class ScimController { @Post('Users') @HttpCode(HttpStatus.CREATED) @ScimJson() - createUser(@Req() req: Request, @Body() body: unknown) { + async createUser(@Req() req: Request, @Body() body: unknown) { + // Provisioning someone new needs Business and a free seat. Updates, + // deactivation and deletion keep working whatever the edition, so a + // directory can always take a leaver's access away. + if (!(await this.edition.hasBusiness())) { + throw new ScimError(403, 'Provisioning new users through SCIM requires AnythingMCP Business.'); + } + const seat = await this.edition.seatAvailable(); + if (!seat.ok) throw new ScimError(403, seatLimitMessage(seat.limit!)); return this.users.create(this.provider(req), body, this.ctx(req)); } @@ -153,7 +163,10 @@ export class ScimController { @Post('Groups') @HttpCode(HttpStatus.CREATED) @ScimJson() - createGroup(@Req() req: Request, @Body() body: unknown) { + async createGroup(@Req() req: Request, @Body() body: unknown) { + if (!(await this.edition.hasBusiness())) { + throw new ScimError(403, 'Provisioning new groups through SCIM requires AnythingMCP Business.'); + } return this.groups.create(this.provider(req), body, this.ctx(req)); } diff --git a/packages/backend/src/identity-providers/sso.controller.ts b/packages/backend/src/identity-providers/sso.controller.ts index 7088ad36..94acf3e8 100644 --- a/packages/backend/src/identity-providers/sso.controller.ts +++ b/packages/backend/src/identity-providers/sso.controller.ts @@ -74,6 +74,21 @@ const LINK_FAILURE_REASONS = new Map([ * distinguishing "unknown provider" from "you are not a member" would turn * these endpoints into an enumeration oracle for workspaces and accounts. */ +/** + * Sign-in failures that name their cause. Both describe the instance, not the + * person signing in, so they reveal nothing about which accounts exist. + */ +const INSTANCE_FAILURE_REASONS = new Map([ + [ + 'edition_required', + 'Your account has not been set up on this workspace yet. Ask your administrator to invite you.', + ], + [ + 'seat_limit', + 'This workspace has no free user seats. Ask your administrator.', + ], +]); + @ApiTags('SSO') // Whole surface, not only the admin API: the sign-in entry point, the callback // and the identity-linking routes are all part of the same self-hosted-only @@ -194,9 +209,10 @@ export class SsoController { `${frontend}/settings?linkError=${encodeURIComponent(LINK_FAILURE_REASONS.get(reason)!)}`, ); } - return res.redirect( - `${frontend}/login?error=${encodeURIComponent('Sign-in failed. Please try again or contact your administrator.')}`, - ); + const message = + INSTANCE_FAILURE_REASONS.get(reason) ?? + 'Sign-in failed. Please try again or contact your administrator.'; + return res.redirect(`${frontend}/login?error=${encodeURIComponent(message)}`); } } diff --git a/packages/backend/src/identity-providers/sso.service.spec.ts b/packages/backend/src/identity-providers/sso.service.spec.ts index 8a8e4fad..75ccc63e 100644 --- a/packages/backend/src/identity-providers/sso.service.spec.ts +++ b/packages/backend/src/identity-providers/sso.service.spec.ts @@ -15,6 +15,7 @@ describe('SsoService', () => { let service: any; let prisma: any; let securityEvents: { log: jest.Mock }; + let edition: { hasBusiness: jest.Mock; seatAvailable: jest.Mock }; const provider = { id: 'p1', @@ -44,6 +45,10 @@ describe('SsoService', () => { $transaction: jest.fn((fn: any) => fn(prisma)), }; securityEvents = { log: jest.fn() }; + edition = { + hasBusiness: jest.fn(async () => true), + seatAvailable: jest.fn(async () => ({ ok: true, limit: null })), + }; service = new SsoService( prisma, { get: () => 'http://localhost:3000' } as any, @@ -52,6 +57,7 @@ describe('SsoService', () => { { generateToken: jest.fn(() => 'jwt') } as any, securityEvents as any, { syncOnLogin: jest.fn(async () => ({ applied: false, reason: 'disabled' })) } as any, + edition as any, ); }); @@ -166,6 +172,42 @@ describe('SsoService', () => { }); }); + describe('account creation and the edition', () => { + const resolveJit = () => + service.resolveUser( + { ...provider, jitProvisioning: true }, + 'a-stable-object-id', + goodClaims.tid, + goodClaims, + {}, + ); + + beforeEach(() => { + prisma.userIdentity.findUnique.mockResolvedValue(null); + prisma.user.findUnique.mockResolvedValue(null); + prisma.user.create.mockResolvedValue({ id: 'new-user' }); + }); + + it('does not create an account without Business', async () => { + edition.hasBusiness.mockResolvedValue(false); + await expect(resolveJit()).rejects.toThrow(expect.objectContaining({ reason: 'edition_required' })); + expect(prisma.user.create).not.toHaveBeenCalled(); + }); + + it('does not create an account when the instance has no free seat', async () => { + edition.seatAvailable.mockResolvedValue({ ok: false, limit: 3 }); + await expect(resolveJit()).rejects.toThrow(expect.objectContaining({ reason: 'seat_limit' })); + expect(prisma.user.create).not.toHaveBeenCalled(); + }); + + it('still signs in a known identity without Business', async () => { + edition.hasBusiness.mockResolvedValue(false); + prisma.userIdentity.findUnique.mockResolvedValue({ id: 'i1', userId: 'u1' }); + expect(await resolveJit()).toBe('u1'); + expect(edition.hasBusiness).not.toHaveBeenCalled(); + }); + }); + describe('returnTo', () => { it.each([ ['an absolute URL', 'https://evil.tld'], diff --git a/packages/backend/src/identity-providers/sso.service.ts b/packages/backend/src/identity-providers/sso.service.ts index 879763c5..800c2f25 100644 --- a/packages/backend/src/identity-providers/sso.service.ts +++ b/packages/backend/src/identity-providers/sso.service.ts @@ -13,6 +13,7 @@ import { } from '../audit/security-event.service'; import { IdentityProvidersService } from './identity-providers.service'; import { assertIssuerAllowed, MSA_TENANT_ID } from './provider-config'; +import { EditionService } from '../ee/licensing/edition.service'; /** * Thrown for every rejection. The `reason` is audited; the message is generic. @@ -70,6 +71,7 @@ export class SsoService { private readonly authService: AuthService, private readonly securityEvents: SecurityEventService, private readonly roleSync: RoleSyncService, + private readonly edition: EditionService, ) {} /** @@ -884,6 +886,17 @@ export class SsoService { where: { email }, select: { id: true }, }); + if (!collision) { + // Creating accounts on first sign-in needs Business and a free seat. + // People who already have an identity here never reach this point, so + // their sign-in is unaffected. + if (!(await this.edition.hasBusiness())) { + throw new SsoError('edition_required', 'Automatic account creation is not available'); + } + if (!(await this.edition.seatAvailable()).ok) { + throw new SsoError('seat_limit', 'No free user seat'); + } + } if (collision) { // A local account already owns this address. Auto-linking here is the // takeover we refuse; the user must link it deliberately while signed in. diff --git a/packages/backend/src/license/license.service.ts b/packages/backend/src/license/license.service.ts index a7b78621..43c2afb3 100644 --- a/packages/backend/src/license/license.service.ts +++ b/packages/backend/src/license/license.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import axios from 'axios'; import * as crypto from 'crypto'; import { PrismaService } from '../common/prisma.service'; @@ -43,9 +43,10 @@ export interface RemoteVerifyResponse { } @Injectable() -export class LicenseService implements OnModuleInit { +export class LicenseService implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(LicenseService.name); private readonly apiBase = LICENSE_API_URL; + private reverifyTimer: NodeJS.Timeout | null = null; constructor( private readonly prisma: PrismaService, @@ -56,6 +57,17 @@ export class LicenseService implements OnModuleInit { async onModuleInit() { await this.ensureInstanceId(); await this.verifyOnStartup(); + // A self-hosted instance can run for months without a restart; without + // this, a cancelled or renewed licence would only be noticed at the next + // one. verifyOnStartup skips keys verified in the last 24 hours. + if (!this.deployment.isCloud()) { + this.reverifyTimer = setInterval(() => void this.verifyOnStartup(), 6 * 60 * 60 * 1000); + this.reverifyTimer.unref(); + } + } + + onModuleDestroy() { + if (this.reverifyTimer) clearInterval(this.reverifyTimer); } // ── Instance ID ──────────────────────────────────────────────────────────── diff --git a/packages/backend/src/users/user-lifecycle.service.spec.ts b/packages/backend/src/users/user-lifecycle.service.spec.ts index 5eb68207..d90d6c3e 100644 --- a/packages/backend/src/users/user-lifecycle.service.spec.ts +++ b/packages/backend/src/users/user-lifecycle.service.spec.ts @@ -17,6 +17,7 @@ describe('UserLifecycleService', () => { let organizations: any; let events: any[]; let service: UserLifecycleService; + let edition: { seatAvailable: jest.Mock }; const admin = { reason: 'admin' as const, actor: { type: 'USER' as const, userId: 'admin-1' } }; const scim = { reason: 'scim' as const, actor: { type: 'SYSTEM' as const }, providerId: 'idp-1' }; @@ -46,10 +47,12 @@ describe('UserLifecycleService', () => { $transaction: jest.fn((fn: any) => fn(prisma)), }; organizations = { assertNotLastAdmin: jest.fn(async () => undefined) }; + edition = { seatAvailable: jest.fn(async () => ({ ok: true, limit: null })) }; service = new UserLifecycleService( prisma, organizations, new SecurityEventService(prisma as unknown as PrismaService), + edition as any, ); }); @@ -202,6 +205,16 @@ describe('UserLifecycleService', () => { expect(eventNames()).toEqual(['USER_REACTIVATED']); }); + // Reactivating is adding a person back: it takes a seat like an invitation. + it('refuses without writes when the instance has no free seat', async () => { + prisma.organizationMember.findUnique.mockResolvedValue({ role: 'EDITOR', deactivatedAt: new Date() }); + edition.seatAvailable.mockResolvedValueOnce({ ok: false, limit: 3 }); + expect(await service.reactivateInOrganization(USER, ORG, scim)).toEqual({ status: 'seat_limit', limit: 3 }); + expect(edition.seatAvailable).toHaveBeenCalledWith(USER); + expect(prisma.organizationMember.update).not.toHaveBeenCalled(); + expect(eventNames()).toEqual([]); + }); + it('does not touch the active org when the user already has one', async () => { prisma.organizationMember.findUnique.mockResolvedValue({ role: 'EDITOR', deactivatedAt: new Date() }); prisma.user.findUnique.mockResolvedValue({ organizationId: 'org-2' }); diff --git a/packages/backend/src/users/user-lifecycle.service.ts b/packages/backend/src/users/user-lifecycle.service.ts index 9b15cbe5..bfc24103 100644 --- a/packages/backend/src/users/user-lifecycle.service.ts +++ b/packages/backend/src/users/user-lifecycle.service.ts @@ -7,6 +7,7 @@ import { } from '../audit/security-event.service'; import { OrganizationsService } from '../organizations/organizations.service'; import { LastAdminConflictException } from '../organizations/last-admin.exception'; +import { EditionService } from '../ee/licensing/edition.service'; export type LifecycleReason = 'admin' | 'scim' | 'system'; @@ -38,7 +39,9 @@ export type DeactivateResult = export type ReactivateResult = | { status: 'reactivated'; role: UserRole } | { status: 'already_active' } - | { status: 'not_a_member' }; + | { status: 'not_a_member' } + /** The instance has no free user seat for someone not active elsewhere. */ + | { status: 'seat_limit'; limit: number }; /** * The one place that removes a person's access to a workspace. @@ -57,6 +60,7 @@ export class UserLifecycleService { private readonly prisma: PrismaService, private readonly organizations: OrganizationsService, private readonly securityEvents: SecurityEventService, + private readonly edition: EditionService, ) {} async deactivateInOrganization( @@ -208,6 +212,9 @@ export class UserLifecycleService { if (!membership) return { status: 'not_a_member' }; if (!membership.deactivatedAt) return { status: 'already_active' }; + const seat = await this.edition.seatAvailable(userId); + if (!seat.ok) return { status: 'seat_limit', limit: seat.limit! }; + await this.prisma.$transaction(async (tx) => { await tx.organizationMember.update({ where: { userId_organizationId: { userId, organizationId } }, diff --git a/packages/backend/src/users/users.controller.ts b/packages/backend/src/users/users.controller.ts index 8054a89e..7addbb79 100644 --- a/packages/backend/src/users/users.controller.ts +++ b/packages/backend/src/users/users.controller.ts @@ -14,6 +14,7 @@ import { UnauthorizedException, BadRequestException, NotFoundException, + ForbiddenException, } from '@nestjs/common'; import { ApiTags, ApiBearerAuth, ApiOperation, ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { AuthGuard } from '@nestjs/passport'; @@ -24,6 +25,7 @@ import { AuthService } from '../auth/auth.service'; import { Roles, RolesGuard } from '../auth/roles.guard'; import { OrganizationsService } from '../organizations/organizations.service'; import { UserLifecycleService, LifecycleContext } from './user-lifecycle.service'; +import { SEAT_LIMIT, seatLimitMessage } from '../ee/licensing/edition.service'; class RevokeSessionsDto { @ApiPropertyOptional({ @@ -355,6 +357,9 @@ export class UsersController { ); if (result.status === 'not_a_member') throw new NotFoundException('User not found'); if (result.status === 'already_active') return { message: 'User is already active' }; + if (result.status === 'seat_limit') { + throw new ForbiddenException({ statusCode: 403, code: SEAT_LIMIT, message: seatLimitMessage(result.limit) }); + } return { message: 'User reactivated' }; } diff --git a/packages/frontend/src/app/login/page.tsx b/packages/frontend/src/app/login/page.tsx index de2fcd4f..3b3c5722 100644 --- a/packages/frontend/src/app/login/page.tsx +++ b/packages/frontend/src/app/login/page.tsx @@ -303,6 +303,20 @@ function LoginForm() { } }; + // A company starts on the Business trial right away: it runs on this + // instance, with no payment details and nothing sent anywhere. + const handleCompanyUse = async () => { + setError(''); + setLoading(true); + try { + await license.startBusinessTrial(authToken); + router.push(redirectTo); + } catch (err: any) { + setError(err.message || 'Could not start the trial'); + setLoading(false); + } + }; + const handleCommercialChoice = () => { setSetupStep('license-key'); }; @@ -511,8 +525,7 @@ function LoginForm() { How will you use AnythingMCP?

- AnythingMCP is open source under AGPL-3.0 and nothing here is locked - — this only records which licence this instance runs under. + You can change this later under Settings → License.

{error &&
{error}
} @@ -523,27 +536,33 @@ function LoginForm() { disabled={loading} className="w-full border border-[var(--border)] rounded-[9px] p-4 text-left hover:border-[var(--brand)] hover:bg-[var(--brand-tint)] transition-colors disabled:opacity-50" > -
Open source (AGPL-3.0)
+
Personal, education or evaluation
- Personal, internal company or evaluation use. We email you a free + Community edition, free, for up to 3 users. We email you a community key so you get security and release notices.
-
+
+
diff --git a/packages/frontend/src/app/settings/license/page.tsx b/packages/frontend/src/app/settings/license/page.tsx index 5a601b25..aafb4d46 100644 --- a/packages/frontend/src/app/settings/license/page.tsx +++ b/packages/frontend/src/app/settings/license/page.tsx @@ -8,6 +8,8 @@ import { useManagePlan } from '@/lib/use-manage-plan'; import { Button, buttonVariants } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { cn } from '@/lib/utils'; +import { useEdition, notifyEditionChanged } from '@/lib/use-edition'; +import { EditionCard } from '@/components/edition-card'; interface LicenseStatus { plan: string | null; @@ -30,6 +32,7 @@ export default function LicenseSettingsPage() { const [openingPortal, setOpeningPortal] = useState(false); const managePlan = useManagePlan(); const pricingUrl = usePricingUrl(); + const { edition } = useEdition(); const isCloud = deploymentMode === 'cloud'; // The Stripe billing portal only applies to a real paid subscription — @@ -63,6 +66,7 @@ export default function LicenseSettingsPage() { setMessage(result.message); setLicenseKey(''); await loadStatus(); + notifyEditionChanged(); } catch (err: any) { setError(err.message || 'Failed to activate license'); } finally { @@ -83,6 +87,7 @@ export default function LicenseSettingsPage() { setError(result.error || 'License is invalid'); } await loadStatus(); + notifyEditionChanged(); } catch (err: any) { setError(err.message || 'Verification failed'); } finally { @@ -194,9 +199,11 @@ export default function LicenseSettingsPage() {
)} + {edition && } + {/* Current Plan */} -

Current Plan

+

{isCloud ? 'Current Plan' : 'License key'}

{!status || !status.plan ? (
@@ -208,9 +215,14 @@ export default function LicenseSettingsPage() { {loading ? 'Activating...' : 'Start 7-Day Free Trial'} ) : ( - + <> +

+ Optional for Community: a free key by email gets you security and release notices. +

+ + )}
) : ( @@ -265,7 +277,7 @@ export default function LicenseSettingsPage() {
{/* Features */} - {status?.features && Object.keys(status.features).length > 0 && ( + {isCloud && status?.features && Object.keys(status.features).length > 0 && (

Features

diff --git a/packages/frontend/src/app/settings/users/page.tsx b/packages/frontend/src/app/settings/users/page.tsx index dfe0f68f..84a4dc65 100644 --- a/packages/frontend/src/app/settings/users/page.tsx +++ b/packages/frontend/src/app/settings/users/page.tsx @@ -9,6 +9,8 @@ import { AppSelect } from '@/components/ui/select'; import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { Badge, StatusPill } from '@/components/ui/badge'; +import { useEdition } from '@/lib/use-edition'; +import { BusinessNotice } from '@/components/business-notice'; const ROLES = ['ADMIN', 'EDITOR', 'VIEWER'] as const; @@ -23,6 +25,9 @@ export default function SettingsUsersPage() { const [roleList, setRoleList] = useState([]); const [loading, setLoading] = useState(true); const [msg, setMsg] = useState(''); + const { edition, reload: reloadEdition } = useEdition(); + const seatsFull = + edition !== null && edition.seatLimit !== null && edition.seatsUsed >= edition.seatLimit; // Invite form const [showInvite, setShowInvite] = useState(false); @@ -101,6 +106,7 @@ export default function SettingsUsersPage() { prev.map((u) => (u.id === userId ? { ...u, active: true, deactivatedAt: null } : u)), ); setMsg('User reactivated. Revoked MCP keys stay revoked — they can create a new one.'); + reloadEdition(); } catch (err: any) { setMsg(`Error: ${err.message}`); } @@ -138,6 +144,7 @@ export default function SettingsUsersPage() { await users.delete(userId, token); setUserList((prev) => prev.filter((u) => u.id !== userId)); setMsg('User deleted'); + reloadEdition(); } catch (err: any) { setMsg(`Error: ${err.message}`); } @@ -247,9 +254,19 @@ export default function SettingsUsersPage() {

User Management

-

Manage users and send invitations.

+

+ Manage users and send invitations. + {edition && edition.seatLimit !== null && ( + <> + {' '} + {edition.seatsUsed <= edition.seatLimit + ? `${edition.seatsUsed} of ${edition.seatLimit} users on this instance.` + : `${edition.seatsUsed} users on this instance, ${edition.seatLimit} included.`} + + )} +

-
@@ -261,6 +278,21 @@ export default function SettingsUsersPage() {
)} + {seatsFull && !edition!.business && ( + + )} + + {seatsFull && edition!.business && ( +
+ Your license includes {edition!.seatLimit} users. To invite more people, change your plan under{' '} + Settings → License. +
+ )} + {/* Invite User Form */} {showInvite && ( diff --git a/packages/frontend/src/components/app-shell.tsx b/packages/frontend/src/components/app-shell.tsx index 4d0a6c42..b0971d6a 100644 --- a/packages/frontend/src/components/app-shell.tsx +++ b/packages/frontend/src/components/app-shell.tsx @@ -7,6 +7,7 @@ import { AppSidebar } from '@/components/app-sidebar'; import { Footer } from '@/components/footer'; import { TrialBanner } from '@/components/trial-banner'; import { UsageBanner } from '@/components/usage-banner'; +import { EditionBanner } from '@/components/edition-banner'; import { cn } from '@/lib/utils'; interface AppShellProps { @@ -89,6 +90,7 @@ export function AppShell({
+
{/* Content header */} {/* Phones get two rows: the title owns the first one — so it is never diff --git a/packages/frontend/src/components/business-notice.tsx b/packages/frontend/src/components/business-notice.tsx new file mode 100644 index 00000000..87f32d7c --- /dev/null +++ b/packages/frontend/src/components/business-notice.tsx @@ -0,0 +1,67 @@ +'use client'; + +import { useState } from 'react'; +import Link from 'next/link'; +import { license, type EditionState } from '@/lib/api'; +import { useAuth } from '@/lib/auth-context'; +import { usePricingUrl } from '@/lib/use-pricing-url'; +import { notifyEditionChanged } from '@/lib/use-edition'; +import { Button, buttonVariants } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +/** Inline notice on a page whose setup needs Business, with the ways to get it. */ +export function BusinessNotice({ + edition, + title, + body, +}: { + edition: EditionState; + title: string; + body: string; +}) { + const { token } = useAuth(); + const pricingUrl = usePricingUrl(); + const [starting, setStarting] = useState(false); + const [error, setError] = useState(''); + + const startTrial = async () => { + if (!token) return; + setStarting(true); + setError(''); + try { + await license.startBusinessTrial(token); + notifyEditionChanged(); + } catch (err: any) { + setError(err.message || 'Could not start the trial.'); + } finally { + setStarting(false); + } + }; + + return ( +
+ ); +} diff --git a/packages/frontend/src/components/edition-banner.tsx b/packages/frontend/src/components/edition-banner.tsx new file mode 100644 index 00000000..43e4d767 --- /dev/null +++ b/packages/frontend/src/components/edition-banner.tsx @@ -0,0 +1,64 @@ +'use client'; + +import { useState } from 'react'; +import Link from 'next/link'; +import { useAuth } from '@/lib/auth-context'; +import { useEdition, daysUntil, formatDay } from '@/lib/use-edition'; + +/** + * Self-hosted, administrators only: a one-line notice when the edition is + * about to change or the instance has used every user Community includes. + * Never blocks anything; dismissible for the session. + */ +export function EditionBanner() { + const { user } = useAuth(); + const { edition } = useEdition(); + const [dismissed, setDismissed] = useState(false); + + if (!edition || user?.role !== 'ADMIN' || dismissed) return null; + + let text: string | null = null; + let tone: 'info' | 'warn' = 'info'; + + if (edition.source === 'trial' && edition.trialEndsAt) { + const left = daysUntil(edition.trialEndsAt) ?? 0; + // The whole trial would be noisy; the last week is when it matters. + if (left <= 7) { + text = + left === 0 + ? 'Your Business trial ends today.' + : `Business trial: ${left} day${left === 1 ? '' : 's'} left.`; + tone = left <= 2 ? 'warn' : 'info'; + } + } 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.seatLimit !== null && + edition.seatsUsed >= edition.seatLimit + ) { + text = `This instance has ${edition.seatsUsed} active users; Community includes ${edition.seatLimit}.`; + } + + if (!text) return null; + + return ( +
+ {text}{' '} + + {edition.trialAvailable ? 'Try Business' : 'See options'} + + +
+ ); +} diff --git a/packages/frontend/src/components/edition-card.tsx b/packages/frontend/src/components/edition-card.tsx new file mode 100644 index 00000000..0eed126f --- /dev/null +++ b/packages/frontend/src/components/edition-card.tsx @@ -0,0 +1,130 @@ +'use client'; + +import { useState } from 'react'; +import { license, type EditionState } from '@/lib/api'; +import { useAuth } from '@/lib/auth-context'; +import { usePricingUrl } from '@/lib/use-pricing-url'; +import { notifyEditionChanged, daysUntil, formatDay } from '@/lib/use-edition'; +import { Button, buttonVariants } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { cn } from '@/lib/utils'; + +export const BUSINESS_CAPABILITIES = [ + 'Single sign-on with Entra ID, Google, Okta or any OIDC provider', + 'SCIM provisioning and role sync from directory groups', + 'Require single sign-on for the whole workspace', +]; + +/** Self-hosted: which edition runs, its users, and the way to Business. */ +export function EditionCard({ edition, onChange }: { edition: EditionState; onChange?: () => void }) { + const { token } = useAuth(); + const pricingUrl = usePricingUrl(); + const [starting, setStarting] = useState(false); + const [error, setError] = useState(''); + + const startTrial = async () => { + if (!token) return; + setError(''); + setStarting(true); + try { + await license.startBusinessTrial(token); + notifyEditionChanged(); + onChange?.(); + } catch (err: any) { + setError(err.message || 'Could not start the trial.'); + } finally { + setStarting(false); + } + }; + + const source = + edition.source === 'license' + ? `License (${edition.plan})` + : edition.source === 'trial' && edition.trialEndsAt + ? `Trial, ${daysUntil(edition.trialEndsAt)} days left` + : edition.source === 'transition' && edition.transitionUntil + ? `Until ${formatDay(edition.transitionUntil)}` + : null; + + const users = + edition.seatLimit === null + ? `${edition.seatsUsed} active user${edition.seatsUsed === 1 ? '' : 's'}` + : edition.seatsUsed <= edition.seatLimit + ? `${edition.seatsUsed} of ${edition.seatLimit} active users` + : `${edition.seatsUsed} active users, ${edition.seatLimit} included`; + + return ( + +
+

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

+ {source && {source}} +
+

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

+ +
+
+ {edition.business ? 'Included' : 'With Business'} +
+
    +
  • + + {edition.business ? '✓' : '+'} + + More than {edition.communitySeatLimit} users +
  • + {BUSINESS_CAPABILITIES.map((c) => ( +
  • + + {edition.business ? '✓' : '+'} + + {c} +
  • + ))} +
+
+ + {edition.source === 'transition' && ( +

+ This instance was already using these, so they stay available until{' '} + {formatDay(edition.transitionUntil!)}. Activate a license key to keep them after that. Users and + sign-ins that exist today keep working either way. +

+ )} + {edition.source === 'trial' && ( +

+ When the trial ends nothing is deleted and nobody is signed out: existing users and single sign-on + identities keep working, and adding users or changing the single sign-on setup needs a license key. +

+ )} + + {error &&

{error}

} + + {edition.source !== 'license' && ( +
+ {edition.trialAvailable && ( + + )} + + View plans + +
+ )} + {edition.trialAvailable && ( +

No payment details, no email: the trial runs on this instance.

+ )} +
+ ); +} diff --git a/packages/frontend/src/components/license-wall.tsx b/packages/frontend/src/components/license-wall.tsx index c5f57985..b2d20051 100644 --- a/packages/frontend/src/components/license-wall.tsx +++ b/packages/frontend/src/components/license-wall.tsx @@ -47,18 +47,17 @@ export function LicenseWall() { // doesn't keep covering the login page. Without this reset the modal // stays mounted after Logout because we'd only ever *set* `reason`, // never unset it. - if (!token) { + // Self-hosted is never walled: without a valid licence it simply runs + // Community (see useEdition), including when a key expires or is revoked. + if (!token || !isCloud) { setReason(null); return; } license.getStatus(token).then((status) => { - // Cloud: no license at all means the org is not allowed to use the - // product. Self-hosted: a missing license means "running on the - // community tier", which is permitted. + // No license at all means the org is not allowed to use the product. if (!status.plan) { - if (isCloud) setReason('no-license'); - else setReason(null); + setReason('no-license'); return; } // Block when trial is expired diff --git a/packages/frontend/src/components/onboarding-redirect.tsx b/packages/frontend/src/components/onboarding-redirect.tsx index 17521480..ae3f1be5 100644 --- a/packages/frontend/src/components/onboarding-redirect.tsx +++ b/packages/frontend/src/components/onboarding-redirect.tsx @@ -33,8 +33,8 @@ function isExcluded(pathname: string | null): boolean { * * Gate precedence (matches LicenseWall + login multi-step flows): * 1. Email must be verified — unverified users see verify prompts. - * 2. License must be active (cloud has a plan, or self-host community - * personal-use already chosen). LicenseWall reads the same source. + * 2. Cloud: the license must be active. LicenseWall reads the same + * source. Self-hosted is never blocked here. * 3. Wizard not yet completed/skipped (onboardingCompletedAt === null). * 4. User has zero connectors — once they own at least one, the wizard * is moot and we auto-stamp completion (so they don't re-see it). @@ -82,7 +82,7 @@ export function OnboardingRedirect() { if (me?.emailVerified === false) return; // License gate — mirror LicenseWall's logic exactly so the two - // never disagree. Self-host with no plan = community tier OK. + // never disagree. It walls Cloud only; self-hosted runs Community. const isCloud = deploymentMode === 'cloud'; const noPlan = !lic.plan; const trialEnded = @@ -90,7 +90,7 @@ export function OnboardingRedirect() { typeof lic.trialDaysLeft === 'number' && lic.trialDaysLeft <= 0; const expired = lic.status === 'expired' || lic.status === 'revoked'; - const licenseBlocking = (isCloud && noPlan) || trialEnded || expired; + const licenseBlocking = isCloud && (noPlan || trialEnded || expired); if (licenseBlocking) return; const hasConnector = (connList?.length ?? 0) > 0; diff --git a/packages/frontend/src/components/usage-banner.tsx b/packages/frontend/src/components/usage-banner.tsx index 6967b57b..383b4e85 100644 --- a/packages/frontend/src/components/usage-banner.tsx +++ b/packages/frontend/src/components/usage-banner.tsx @@ -20,7 +20,7 @@ const NEXT_TIER: Record = { * advisory by product decision (May 2026). */ export function UsageBanner() { - const { token } = useAuth(); + const { token, deploymentMode } = useAuth(); const [usage, setUsage] = useState(null); const [dismissed, setDismissed] = useState(false); const managePlan = useManagePlan({ @@ -29,10 +29,12 @@ export function UsageBanner() { utm_campaign: 'usage-cap', }); + // Cloud only: these are the Cloud plans' limits. A self-hosted instance has + // its own (EditionBanner). useEffect(() => { - if (!token) return; + if (!token || deploymentMode !== 'cloud') return; license.getUsage(token).then(setUsage).catch(() => {}); - }, [token]); + }, [token, deploymentMode]); if (!usage || !usage.plan || !usage.isOverAny || dismissed) return null; const next = NEXT_TIER[usage.plan]; diff --git a/packages/frontend/src/lib/api.ts b/packages/frontend/src/lib/api.ts index d03649b8..4ae64e38 100644 --- a/packages/frontend/src/lib/api.ts +++ b/packages/frontend/src/lib/api.ts @@ -1162,8 +1162,36 @@ export const mcpKeys = { request(`/api/mcp-keys/${id}`, { method: 'DELETE', token }), }; +/** GET /api/license/edition — see packages/backend/src/ee/licensing/edition.service.ts. */ +export interface EditionState { + edition: 'cloud' | 'community' | 'business'; + business: boolean; + source: 'license' | 'trial' | 'transition' | null; + plan: string | null; + seatLimit: number | null; + seatsUsed: number; + communitySeatLimit: number; + trialEndsAt: string | null; + trialAvailable: boolean; + trialDays: number; + transitionUntil: string | null; +} + +/** Error codes the backend attaches to edition refusals. */ +export const EDITION_REQUIRED = 'edition_required'; +export const SEAT_LIMIT = 'seat_limit'; + +export function editionErrorCode(err: unknown): string | null { + const code = err instanceof ApiError ? err.body?.code : null; + return code === EDITION_REQUIRED || code === SEAT_LIMIT ? code : null; +} + // License export const license = { + getEdition: (token: string) => + request('/api/license/edition', { token }), + startBusinessTrial: (token: string) => + request('/api/license/business-trial', { method: 'POST', token }), getStatus: (token?: string) => request<{ plan: string | null; status: string; features: any; expiresAt: string | null; lastVerifiedAt: string | null; instanceId: string | null; trialDaysLeft?: number }>('/api/license/status', { token }), activateTrial: (token: string) => diff --git a/packages/frontend/src/lib/marketing.ts b/packages/frontend/src/lib/marketing.ts index 92f26375..cd2b7f72 100644 --- a/packages/frontend/src/lib/marketing.ts +++ b/packages/frontend/src/lib/marketing.ts @@ -16,12 +16,18 @@ export function getMarketingUrl(): string { * the caller may pass the user's own Google Ads click id (see usePricingUrl), * which the pricing page puts into the checkout so the purchase can be * reported to Google Ads; anything that is not a well-formed id is left out. + * A self-hosted instance opens the page on its own plans (`hosting=self-hosted`). */ -export function buildPricingUrl(returnPath = '/settings/license/activate', clickIds?: ClickIds | null): string { +export function buildPricingUrl( + returnPath = '/settings/license/activate', + clickIds?: ClickIds | null, + selfHosted = false, +): string { const base = `${getMarketingUrl()}/pricing`; - if (typeof window === 'undefined') return base; + if (typeof window === 'undefined') return selfHosted ? `${base}?hosting=self-hosted` : base; const returnUrl = `${window.location.origin}${returnPath}`; let url = `${base}?return_url=${encodeURIComponent(returnUrl)}`; + if (selfHosted) url += '&hosting=self-hosted'; for (const key of CLICK_ID_KEYS) { const id = cleanClickId(clickIds?.[key]); if (id) url += `&${key}=${id}`; diff --git a/packages/frontend/src/lib/use-edition.ts b/packages/frontend/src/lib/use-edition.ts new file mode 100644 index 00000000..63c0c072 --- /dev/null +++ b/packages/frontend/src/lib/use-edition.ts @@ -0,0 +1,45 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { license, type EditionState } from './api'; +import { useAuth } from './auth-context'; + +const CHANGED = 'amcp:edition-changed'; + +/** Tell every mounted useEdition to reload, e.g. after a trial started or a key was activated. */ +export function notifyEditionChanged(): void { + if (typeof window !== 'undefined') window.dispatchEvent(new Event(CHANGED)); +} + +/** + * The edition of this self-hosted instance (Community or Business), its user + * limit and whether the Business trial is still available. Null on Cloud, + * while loading, and when signed out. + */ +export function useEdition(): { edition: EditionState | null; reload: () => void } { + const { token, deploymentMode, deploymentModeLoaded } = useAuth(); + const [edition, setEdition] = useState(null); + const selfHosted = deploymentModeLoaded && deploymentMode !== 'cloud'; + + const reload = useCallback(() => { + if (!token || !selfHosted) return; + license.getEdition(token).then(setEdition).catch(() => setEdition(null)); + }, [token, selfHosted]); + + useEffect(() => { + reload(); + window.addEventListener(CHANGED, reload); + return () => window.removeEventListener(CHANGED, reload); + }, [reload]); + + return { edition: token && selfHosted ? edition : null, reload }; +} + +export function daysUntil(iso: string | null): number | null { + if (!iso) return null; + return Math.max(0, Math.ceil((new Date(iso).getTime() - Date.now()) / 86_400_000)); +} + +export function formatDay(iso: string): string { + return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }); +} diff --git a/packages/frontend/src/lib/use-pricing-url.ts b/packages/frontend/src/lib/use-pricing-url.ts index f0404c3d..a618bab0 100644 --- a/packages/frontend/src/lib/use-pricing-url.ts +++ b/packages/frontend/src/lib/use-pricing-url.ts @@ -48,5 +48,5 @@ export function usePricingUrl(returnPath?: string): string { }, [isCloud, token]); const clickIds = isCloud && token && found?.token === token ? found.ids : null; - return buildPricingUrl(returnPath, clickIds); + return buildPricingUrl(returnPath, clickIds, deploymentModeLoaded && !isCloud); }