diff --git a/docs/cloud-plans.md b/docs/cloud-plans.md new file mode 100644 index 000000000..b33a01572 --- /dev/null +++ b/docs/cloud-plans.md @@ -0,0 +1,58 @@ +# Cloud plan limits + +This page applies to AnythingMCP Cloud only. A self-hosted instance has none of +these limits: there, the Community edition counts active users, and +Enterprise features follow the licence key (see [license FAQ](license-faq.md)). + +## What each plan includes + +| | Trial (7 days) | Starter | Team | Enterprise | +|---|---|---|---|---| +| Active connectors | 15 | 5 | 15 | unlimited | +| Active MCP servers | 10 | 3 | 10 | unlimited | +| Users | 3 | 1 | 3 | by contract | +| File Search | Team limits | 1 folder, 500 files | 3 folders, 2,000 files | by contract | +| Code mode | Team features | 5 saved code tools, no inline code | 20 saved code tools, inline code | unlimited | + +The trial includes everything in Team, so you can try every feature before you +choose a plan. File Search and code mode have their own limits, described in +[File Search](connectors/file-search.md#limits-and-pauses) and +[code mode](code-mode.md#limits). + +Only **active** connectors and MCP servers count. A File Search folder counts +against the File Search folders, not against the connectors. + +## Before you choose a plan + +When the trial ends, the plan picker shows what changes with each plan for +your workspace, for example "4 of your 9 connectors would be paused". Choose +**See which ones** to see which connectors would stay active and which would +be paused. Team is always one click away. + +`GET /api/license/plan-fit` returns the same answer for scripts and the +[CLI](../packages/cli/README.md#check-what-a-plan-change-would-pause-cloud). + +## When a plan includes fewer active items + +When a workspace moves to a plan with lower limits (the trial to Starter, or +Team to Starter): + +- the connectors and MCP servers used most in the last 30 days stay active; +- the others are **paused**. Nothing is deleted: credentials, tools and settings + are kept. Your AI clients no longer see the tools of a paused connector, and + the AnythingMCP tools tell the model which connectors are paused; +- the workspace admins get an email listing what was paused. + +You choose what stays active at any time. Pause a connector, then switch on +another one. At the limit, switching one on asks you to pause another first. +Paused connectors show **Paused: plan limit** in the dashboard. + +When you upgrade again, everything the downgrade paused is switched back on +automatically, up to the new plan's limits. + +Members keep their access when the plan includes fewer users. New +invitations above the limit are refused. + +Over a limit, the API answers `403` with `code: "PLAN_LIMIT"`, the `axis` +(`connectors`, `mcpServers`, `users` or `fileSearchConnectors`), `max` and +`current`. diff --git a/docs/deployment.md b/docs/deployment.md index d1a0e0f0c..3a494d23d 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -391,6 +391,7 @@ curl -s http://localhost:4000/api/mcp-api-keys \ | `SMTP_PASS` | No | SMTP password | | `SMTP_FROM` | No | Sender address (default: `AnythingMCP `) | | `SMTP_REPLY_TO` | No | Reply-To address, for when `SMTP_FROM` is a noreply address | +| `PLAN_CAPS_ENFORCE` | No | Cloud only: how hard the plans' caps on connectors, MCP servers and users are (see [Cloud plan limits](cloud-plans.md)). `dry-run` (default) logs what would be refused or paused, `on` enforces them, `off` does neither. The trial's cap on creating connectors and MCP servers is enforced in every mode. Ignored on self-hosted, which has none of these caps | | `RETENTION_SMTP_HOST` | No | Cloud only: a second SMTP server for the trial lifecycle, win-back and blocked-call emails. Without it they go through the SMTP above, with `RETENTION_SMTP_REPLY_TO` as Reply-To when set. Self-hosted sends none of these emails | | `RETENTION_SMTP_PORT` | No | Its port (default: 587). 465 or 2465 use implicit TLS, any other port STARTTLS | | `RETENTION_SMTP_USER` | No | Its username | diff --git a/llms-full.txt b/llms-full.txt index 178b0b727..e187a9ab4 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -668,6 +668,7 @@ curl -s http://localhost:4000/api/mcp-api-keys \ | `SMTP_PASS` | No | SMTP password | | `SMTP_FROM` | No | Sender address (default: `AnythingMCP `) | | `SMTP_REPLY_TO` | No | Reply-To address, for when `SMTP_FROM` is a noreply address | +| `PLAN_CAPS_ENFORCE` | No | Cloud only: how hard the plans' caps on connectors, MCP servers and users are (see [Cloud plan limits]()). `dry-run` (default) logs what would be refused or paused, `on` enforces them, `off` does neither. The trial's cap on creating connectors and MCP servers is enforced in every mode. Ignored on self-hosted, which has none of these caps | | `RETENTION_SMTP_HOST` | No | Cloud only: a second SMTP server for the trial lifecycle, win-back and blocked-call emails. Without it they go through the SMTP above, with `RETENTION_SMTP_REPLY_TO` as Reply-To when set. Self-hosted sends none of these emails | | `RETENTION_SMTP_PORT` | No | Its port (default: 587). 465 or 2465 use implicit TLS, any other port STARTTLS | | `RETENTION_SMTP_USER` | No | Its username | diff --git a/packages/backend/src/app.module.ts b/packages/backend/src/app.module.ts index 31dc80bc1..3e85e832f 100644 --- a/packages/backend/src/app.module.ts +++ b/packages/backend/src/app.module.ts @@ -1,3 +1,4 @@ +import { PlanCapsModule } from './plan-caps/plan-caps.module'; import { Module, MiddlewareConsumer, NestModule, Logger, ClassSerializerInterceptor } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { join } from 'path'; @@ -182,6 +183,7 @@ if (useOAuth) { McpServersModule, FileSearchModule, LicenseModule, + PlanCapsModule, LicensingModule, // Cloud-specific modules (conditionally loaded) diff --git a/packages/backend/src/audit/product-event.service.ts b/packages/backend/src/audit/product-event.service.ts index bcc968ee4..a1edb6178 100644 --- a/packages/backend/src/audit/product-event.service.ts +++ b/packages/backend/src/audit/product-event.service.ts @@ -339,6 +339,13 @@ export const ProductEvents = { * screen and device; read with checkout_link_created and Stripe. */ PAYWALL_ACTION: 'paywall_action', + /** + * Cloud: a plan change paused items above the new plan's caps + * (metadata.kind 'downgrade') or brought them back ('restore'); + * metadata.plan = the new plan, metadata.calls = how many items, + * metadata.status = 'dry-run' or 'on' (PLAN_CAPS_ENFORCE). PlanCapsService. + */ + PLAN_CAPS_APPLIED: 'plan_caps_applied', } as const; /** The values of `action` on paywall_action (see PAYWALL_ACTION). */ @@ -371,6 +378,7 @@ export type ProductEventName = (typeof ProductEvents)[keyof typeof ProductEvents */ const SERVER_ONLY = new Set([ ProductEvents.SIGNUP_ATTRIBUTED, + ProductEvents.PLAN_CAPS_APPLIED, ProductEvents.OAUTH_FAILED, ProductEvents.AI_CLIENT_CONNECTED, ProductEvents.DIRECTORY_SIGNUP, diff --git a/packages/backend/src/auth/auth.controller.ts b/packages/backend/src/auth/auth.controller.ts index 2dedecb8c..b8fef6401 100644 --- a/packages/backend/src/auth/auth.controller.ts +++ b/packages/backend/src/auth/auth.controller.ts @@ -1,3 +1,4 @@ +import { LicenseGuardService } from '../license/license-guard.service'; import { Controller, Post, @@ -236,6 +237,7 @@ export class AuthController { private readonly productEvents: ProductEventService, private readonly edition: EditionService, @Optional() private readonly adsSignup?: AdsSignupService, + @Optional() private readonly licenseGuard?: LicenseGuardService, ) { // Built from this controller's own dependencies (it is stateless), so the // dashboard endpoints and the MCP authorization page share one @@ -709,6 +711,8 @@ export class AuthController { // Refused here too, not only on acceptance, so the admin learns it before // the invitee does. await this.edition.assertSeatAvailable(existing?.id); + // Cloud: the plan's user cap (PLAN_CAPS_ENFORCE). + await this.licenseGuard?.checkCanAddMember(req.user.organizationId); // 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 @@ -882,6 +886,7 @@ export class AuthController { ); } await this.edition.assertSeatAvailable(existing.id); + await this.licenseGuard?.checkCanAddMember(invite.organizationId); 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); @@ -896,6 +901,7 @@ export class AuthController { throw new BadRequestException('A display name is required.'); } await this.edition.assertSeatAvailable(); + await this.licenseGuard?.checkCanAddMember(invite.organizationId); const passwordHash = await this.authService.hashPassword(dto.password); user = await this.usersService.create({ email: invite.email, diff --git a/packages/backend/src/connectors/connectors.controller.ts b/packages/backend/src/connectors/connectors.controller.ts index 182616273..6086d79fb 100644 --- a/packages/backend/src/connectors/connectors.controller.ts +++ b/packages/backend/src/connectors/connectors.controller.ts @@ -1396,6 +1396,10 @@ export class ConnectorsController { ) { const connector = await this.connectorsService.findById(id); this.assertCanWrite(connector, req); + // Switching a paused connector back on counts against the plan's cap (Cloud). + if (dto.isActive === true && !connector.isActive) { + await this.licenseGuard.checkCanActivateConnector(connector.organizationId, connector.type as string); + } if ((connector.type as string) === 'FILE_SEARCH' && (dto.config !== undefined || dto.baseUrl !== undefined || dto.authConfig !== undefined)) { throw new BadRequestException('Change the source of a File Search connector on its Files tab.'); } diff --git a/packages/backend/src/license/license-guard.service.ts b/packages/backend/src/license/license-guard.service.ts index f6570bb39..9b43d5373 100644 --- a/packages/backend/src/license/license-guard.service.ts +++ b/packages/backend/src/license/license-guard.service.ts @@ -1,10 +1,12 @@ -import { Injectable, ForbiddenException } from '@nestjs/common'; +import { Injectable, ForbiddenException, Logger } from '@nestjs/common'; import { PrismaService } from '../common/prisma.service'; import { LicenseService } from './license.service'; import { DeploymentService } from '../common/deployment.service'; import { cardTrialEnd } from './license-checkout'; import { codeToolCapForPlan, inlineCodeAllowedForPlan, effectiveCodeModePlan } from './code-mode-quotas'; import type { LicenseInfo } from './license.service'; +import { quotaForPlan } from '../file-search/file-search-quotas'; +import { PlanLimitException, planCapsMode, parsePausedByPlan, PAUSED_BY_PLAN_KEY, type PlanCapAxis } from './plan-caps'; export interface UsageCap { current: number; @@ -17,10 +19,16 @@ export interface LicenseUsage { connectors: UsageCap; mcpServers: UsageCap; users: UsageCap; - // True when any axis is over its cap. Frontend uses this to render the - // soft-warn upgrade banner. Caps on paid tiers are advisory only — we no - // longer throw 403 when exceeded (per product decision May 2026). + // True when any axis is over its cap. The frontend renders the upgrade + // banner from it. Whether a paid plan's cap also blocks depends on + // PLAN_CAPS_ENFORCE (plan-caps.ts). isOverAny: boolean; + /** + * Cloud: the connectors and MCP servers the last automatic downgrade paused + * and that are still paused, so the dashboard can mark them. Absent when + * nothing was paused. + */ + pausedByPlan?: { connectors: string[]; mcpServers: string[] }; } /** What an AI client is told when a Cloud workspace's free trial has ended. */ @@ -61,6 +69,8 @@ const PLAN_COUNTED_SERVER = { isActive: true }; @Injectable() export class LicenseGuardService { + private readonly logger = new Logger(LicenseGuardService.name); + constructor( private readonly prisma: PrismaService, private readonly licenseService: LicenseService, @@ -106,49 +116,160 @@ export class LicenseGuardService { } /** - * Soft-warn policy: connector/MCP-server caps on paid tiers DO NOT BLOCK. - * The cap is exposed via getUsage() so the frontend can render an upgrade - * banner. Trial keeps a hard cap because it's a sales tool — abuse risk - * outweighs the friction. + * Connector and MCP server caps (Cloud). The trial's cap on creating them is + * hard, as it always was. On a paid plan the cap blocks only when + * PLAN_CAPS_ENFORCE is `on` (see plan-caps.ts); in `dry-run` it logs what it + * would have refused. Only active items count, so pausing one frees a slot. */ async checkCanCreateConnector(userId: string, organizationId?: string): Promise { if (!this.deployment.isCloud()) return; await this.checkLicenseActive(organizationId); - const license = await this.licenseService.getCurrentLicense(organizationId); - if (license?.plan !== 'trial') return; - - const maxConnectors = (license?.features as any)?.maxConnectors; - if (maxConnectors == null) return; - + const max = (license?.features as any)?.maxConnectors; + if (typeof max !== 'number') return; const count = await this.prisma.connector.count({ where: { ...(organizationId ? { organizationId } : { userId }), ...PLAN_COUNTED_CONNECTOR }, }); - if (count >= maxConnectors) { - throw new ForbiddenException( - `Trial limit reached (${maxConnectors} connectors). A workspace administrator can activate a license to add more.`, + if (count < max) return; + if (license?.plan === 'trial') { + throw new PlanLimitException( + 'connectors', + max, + count, + `Trial limit reached (${max} connectors). A workspace administrator can activate a license to add more.`, ); } + this.refuseOverCap( + organizationId, + 'connectors', + max, + count, + `Your plan includes ${max} active connectors. Pause one under Connectors to add this one, or upgrade the plan.`, + ); } async checkCanCreateMcpServer(userId: string, organizationId?: string): Promise { if (!this.deployment.isCloud()) return; await this.checkLicenseActive(organizationId); - const license = await this.licenseService.getCurrentLicense(organizationId); - if (license?.plan !== 'trial') return; - - const maxMcpServers = (license?.features as any)?.maxMcpServers; - if (maxMcpServers == null) return; - + const max = (license?.features as any)?.maxMcpServers; + if (typeof max !== 'number') return; const count = await this.prisma.mcpServerConfig.count({ where: { ...(organizationId ? { organizationId } : { userId }), ...PLAN_COUNTED_SERVER }, }); - if (count >= maxMcpServers) { - throw new ForbiddenException( - `Trial limit reached (${maxMcpServers} MCP servers). A workspace administrator can activate a license to add more.`, + if (count < max) return; + if (license?.plan === 'trial') { + throw new PlanLimitException( + 'mcpServers', + max, + count, + `Trial limit reached (${max} MCP servers). A workspace administrator can activate a license to add more.`, ); } + this.refuseOverCap( + organizationId, + 'mcpServers', + max, + count, + `Your plan includes ${max} active MCP servers. Pause one under MCP Servers to add this one, or upgrade the plan.`, + ); + } + + /** + * Switching a paused connector back on (Cloud): refused at the cap, so a + * downgrade cannot be undone by re-activating everything. A File Search + * folder counts against the plan's File Search folders instead. + */ + async checkCanActivateConnector(organizationId: string, connectorType?: string): Promise { + if (!this.deployment.isCloud()) return; + const license = await this.licenseService.getCurrentLicense(organizationId); + if (!license) return; + if (String(connectorType) === 'FILE_SEARCH') { + const quota = quotaForPlan(license.plan, { cloud: true, overrides: (license.features as any)?.fileSearch }); + const max = quota?.maxConnectors; + if (max == null) return; + const rows = await this.prisma.connector.findMany({ + where: { organizationId, type: 'FILE_SEARCH' as any, isActive: true }, + select: { config: true }, + }); + const count = rows.filter((r) => !(r.config as { fileShare?: unknown } | null)?.fileShare).length; + if (count < max) return; + this.refuseOverCap( + organizationId, + 'fileSearchConnectors', + max, + count, + `Your plan includes ${max} active File Search folder${max === 1 ? '' : 's'}. Pause one to activate this one, or upgrade the plan.`, + license.plan === 'trial', + ); + return; + } + const max = (license.features as any)?.maxConnectors; + if (typeof max !== 'number') return; + const count = await this.prisma.connector.count({ where: { organizationId, ...PLAN_COUNTED_CONNECTOR } }); + if (count < max) return; + this.refuseOverCap( + organizationId, + 'connectors', + max, + count, + `Your plan includes ${max} active connectors. Pause one to activate this one, or upgrade the plan.`, + license.plan === 'trial', + ); + } + + async checkCanActivateMcpServer(organizationId: string): Promise { + if (!this.deployment.isCloud()) return; + const license = await this.licenseService.getCurrentLicense(organizationId); + const max = (license?.features as any)?.maxMcpServers; + if (typeof max !== 'number') return; + const count = await this.prisma.mcpServerConfig.count({ where: { organizationId, ...PLAN_COUNTED_SERVER } }); + if (count < max) return; + this.refuseOverCap( + organizationId, + 'mcpServers', + max, + count, + `Your plan includes ${max} active MCP servers. Pause one to activate this one, or upgrade the plan.`, + license?.plan === 'trial', + ); + } + + /** + * A new member (invitation or its acceptance) on Cloud: refused at the + * plan's user cap when enforcement is on. Members already in the workspace + * keep their access whatever the plan. + */ + async checkCanAddMember(organizationId: string): Promise { + if (!this.deployment.isCloud() || !organizationId) return; + const license = await this.licenseService.getCurrentLicense(organizationId); + const max = (license?.features as any)?.maxUsers; + if (typeof max !== 'number') return; + const count = await this.prisma.organizationMember.count({ where: { organizationId, deactivatedAt: null } }); + if (count < max) return; + this.refuseOverCap( + organizationId, + 'users', + max, + count, + `Your plan includes ${max} user${max === 1 ? '' : 's'}. Upgrade the plan to invite more people.`, + ); + } + + /** Refuse over a cap when enforcement is on (or always, for `hard`); log in dry-run. */ + private refuseOverCap( + organizationId: string | undefined, + axis: PlanCapAxis, + max: number, + current: number, + message: string, + hard = false, + ): void { + const mode = planCapsMode(); + if (hard || mode === 'on') throw new PlanLimitException(axis, max, current, message); + if (mode === 'dry-run') { + this.logger.log(`[plan caps dry-run] would refuse ${axis} for org ${organizationId}: ${current}/${max}`); + } } /** @@ -256,15 +377,62 @@ export class LicenseGuardService { const mcpServers = wrap(mcpCount, features.maxMcpServers); const users = wrap(userCount, features.maxUsers); + const pausedByPlan = organizationId ? await this.pausedByPlan(organizationId).catch(() => null) : null; + return { plan: license?.plan ?? null, connectors, mcpServers, users, isOverAny: connectors.isOver || mcpServers.isOver || users.isOver, + ...(pausedByPlan && { pausedByPlan }), }; } + /** + * Names of the connectors a plan change paused and that are still paused + * (Cloud), for the AI client's list of connectors; null when there are none. + */ + async pausedByPlanConnectorNames(organizationId?: string): Promise { + if (!organizationId) return null; + const paused = await this.pausedByPlan(organizationId).catch(() => null); + if (!paused?.connectors.length) return null; + const rows = await this.prisma.connector.findMany({ + where: { organizationId, id: { in: paused.connectors } }, + select: { name: true }, + orderBy: { name: 'asc' }, + }); + return rows.length ? rows.map((r) => r.name) : null; + } + + /** What the last downgrade paused and is still paused (Cloud), or null. */ + private async pausedByPlan(organizationId: string): Promise<{ connectors: string[]; mcpServers: string[] } | null> { + if (!this.deployment.isCloud()) return null; + const row = await this.prisma.orgSettings.findUnique({ + where: { organizationId_key: { organizationId, key: PAUSED_BY_PLAN_KEY } }, + select: { value: true }, + }); + const stored = parsePausedByPlan(row?.value); + if (!stored) return null; + const connectorIds = [...stored.connectors, ...stored.fileSearch]; + const [connectors, servers] = await Promise.all([ + connectorIds.length + ? this.prisma.connector.findMany({ + where: { organizationId, id: { in: connectorIds }, isActive: false }, + select: { id: true }, + }) + : Promise.resolve([] as { id: string }[]), + stored.mcpServers.length + ? this.prisma.mcpServerConfig.findMany({ + where: { organizationId, id: { in: stored.mcpServers }, isActive: false }, + select: { id: true }, + }) + : Promise.resolve([] as { id: string }[]), + ]); + if (connectors.length === 0 && servers.length === 0) return null; + return { connectors: connectors.map((c) => c.id), mcpServers: servers.map((c) => c.id) }; + } + /** * Cloud only: the workspace's free trial, running or ended. Null on a paid * plan, with no licence at all, and on self-hosted (whose trial is the diff --git a/packages/backend/src/license/license.service.spec.ts b/packages/backend/src/license/license.service.spec.ts index 87d8ed77c..7f7a48a4c 100644 --- a/packages/backend/src/license/license.service.spec.ts +++ b/packages/backend/src/license/license.service.spec.ts @@ -1,3 +1,4 @@ +import axios from 'axios'; import { LicenseService, isLivePaidLicence, periodEndCheckDue } from './license.service'; type MockLicense = { @@ -1099,3 +1100,63 @@ describe('LicenseService — DPA link', () => { await expect(svc.createDpaLink('org-1', 'user-1')).rejects.toThrow('no paid plan'); }); }); + +describe('LicenseService — plan changes are announced (Cloud)', () => { + let get: jest.SpyInstance; + beforeEach(() => { + get = jest.spyOn(axios, 'get'); + }); + afterEach(() => get.mockRestore()); + + function make(cloud = true) { + const row: any = { licenseKey: 'AMCP-T', organizationId: 'org-1', plan: 'trial', status: 'active', createdAt: new Date() }; + const prisma = { + license: { + findFirst: jest.fn(async ({ where }: any) => + where?.organizationId === 'org-1' && (!where.status || row.status === where.status) ? { ...row } : null, + ), + update: jest.fn(async ({ data }: any) => Object.assign(row, data)), + }, + }; + const svc = new LicenseService(prisma as any, {} as any, { isCloud: () => cloud } as any); + return { svc, row }; + } + const flush = () => new Promise((r) => setImmediate(r)); + + it('tells listeners when a verification changes the plan', async () => { + const { svc } = make(); + const seen: any[] = []; + svc.onPlanChange((c) => { + seen.push(c); + }); + get.mockResolvedValue({ data: { valid: true, plan: 'starter', features: {} } }); + await svc.verifyLicense('AMCP-T', 'org-1'); + await flush(); + expect(seen).toEqual([{ organizationId: 'org-1', from: 'trial', to: 'starter' }]); + }); + + it('stays quiet when the plan is the same, and a failing listener never fails the write', async () => { + const { svc } = make(); + const listener = jest.fn().mockRejectedValue(new Error('boom')); + svc.onPlanChange(listener); + get.mockResolvedValue({ data: { valid: true, plan: 'trial', features: {} } }); + await expect(svc.verifyLicense('AMCP-T', 'org-1')).resolves.toMatchObject({ valid: true }); + await flush(); + expect(listener).not.toHaveBeenCalled(); + + get.mockResolvedValue({ data: { valid: true, plan: 'team', features: {} } }); + await expect(svc.verifyLicense('AMCP-T', 'org-1')).resolves.toMatchObject({ valid: true }); + await flush(); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('announces nothing on self-hosted', async () => { + const { svc } = make(false); + const listener = jest.fn(); + svc.onPlanChange(listener); + get.mockResolvedValue({ data: { valid: true, plan: 'starter', features: {} } }); + await svc.verifyLicense('AMCP-T', 'org-1'); + await flush(); + expect(listener).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/backend/src/license/license.service.ts b/packages/backend/src/license/license.service.ts index f6bad701c..82baa1829 100644 --- a/packages/backend/src/license/license.service.ts +++ b/packages/backend/src/license/license.service.ts @@ -24,6 +24,15 @@ import { collectUsageReport, usageReportEnabled, type UsageReport } from './usag // any self-hosted operator point verification at a server of their own and // unlock paid features. LICENSE_API_URL applies outside production only (local // end-to-end runs against a local licence site). + +/** A Cloud workspace's current plan changed (null = no active licence). */ +export interface PlanChange { + organizationId: string; + from: string | null; + to: string | null; +} +export type PlanChangeListener = (change: PlanChange) => Promise | void; + export const LICENSE_API_URL = process.env.NODE_ENV === 'production' ? 'https://anythingmcp.com' @@ -1017,6 +1026,7 @@ export class LicenseService implements OnModuleInit, OnModuleDestroy { // Self-hosted adds its usage counts (see usage-report.ts); Cloud never. const usage = await this.usageReport(); + const planBefore = await this.cloudPlanOf(organizationId); try { const { data } = await axios.get( @@ -1069,6 +1079,7 @@ export class LicenseService implements OnModuleInit, OnModuleDestroy { .catch(() => { // License may not exist locally yet }); + await this.announcePlanChange(organizationId, planBefore); return data; } catch (err: any) { @@ -1150,6 +1161,8 @@ export class LicenseService implements OnModuleInit, OnModuleDestroy { } } + const planBefore = await this.cloudPlanOf(organizationId); + // Verify remotely first. On Cloud the workspace goes along, so the site // can refuse a self-hosted licence and say why. const verification = await this.verifyLicense(licenseKey, organizationId); @@ -1195,6 +1208,8 @@ export class LicenseService implements OnModuleInit, OnModuleDestroy { await this.siteSettings.set('license_key', licenseKey); } + await this.announcePlanChange(organizationId, planBefore); + // Activate in background this.activateLicense(licenseKey, organizationId).catch((err) => this.logger.warn(`License activation failed: ${err.message}`), @@ -1369,6 +1384,39 @@ export class LicenseService implements OnModuleInit, OnModuleDestroy { return isPaymentFailedBilling(info.billing) ? info : null; } + // ── Plan changes (Cloud) ────────────────────────────────────────────────── + + private readonly planChangeListeners: PlanChangeListener[] = []; + + /** + * Called when a Cloud workspace's current plan changes (checkout, a + * card-trial conversion, a change in the billing portal reported by the + * licence site, a key entered by hand). PlanCapsService listens to pause or + * restore connectors; LicenseService itself knows nothing about them. + */ + onPlanChange(listener: PlanChangeListener): void { + this.planChangeListeners.push(listener); + } + + /** The plan of the workspace's current licence (Cloud only; undefined elsewhere). */ + private async cloudPlanOf(organizationId?: string): Promise { + if (!this.deployment.isCloud() || !organizationId || this.planChangeListeners.length === 0) return undefined; + return (await this.getCurrentLicense(organizationId).catch(() => null))?.plan ?? null; + } + + /** Tell the listeners when the current plan is not the one read before a write. */ + private async announcePlanChange(organizationId: string | undefined, before: string | null | undefined): Promise { + if (before === undefined || !organizationId || this.planChangeListeners.length === 0) return; + const after = await this.cloudPlanOf(organizationId); + if (after === undefined || after === before) return; + for (const listener of this.planChangeListeners) { + // Never let a listener fail or slow down the licence write. + Promise.resolve() + .then(() => listener({ organizationId, from: before, to: after })) + .catch((err) => this.logger.warn(`Plan change listener failed for ${organizationId}: ${err?.message ?? err}`)); + } + } + async getCurrentLicense(organizationId?: string): Promise { // 1. Per-org: find license directly assigned to this organization if (organizationId) { diff --git a/packages/backend/src/license/plan-caps.guard.spec.ts b/packages/backend/src/license/plan-caps.guard.spec.ts new file mode 100644 index 000000000..c31c9a8f9 --- /dev/null +++ b/packages/backend/src/license/plan-caps.guard.spec.ts @@ -0,0 +1,105 @@ +import { LicenseGuardService } from './license-guard.service'; +import { PlanLimitException, PAUSED_BY_PLAN_KEY } from './plan-caps'; + +function make(license: any, counts: { connectors?: number; servers?: number; members?: number; fsActive?: number } = {}, cloud = true) { + const prisma = { + connector: { + count: jest.fn().mockResolvedValue(counts.connectors ?? 0), + findMany: jest.fn(async ({ where }: any) => + where.type === 'FILE_SEARCH' + ? Array.from({ length: counts.fsActive ?? 0 }, () => ({ config: {} })) + : (where.id?.in ?? []).map((id: string) => ({ id, name: `Name ${id}` })), + ), + }, + mcpServerConfig: { + count: jest.fn().mockResolvedValue(counts.servers ?? 0), + findMany: jest.fn(async ({ where }: any) => (where.id?.in ?? []).map((id: string) => ({ id }))), + }, + organizationMember: { count: jest.fn().mockResolvedValue(counts.members ?? 1) }, + orgSettings: { findUnique: jest.fn().mockResolvedValue(null) }, + }; + const licenseService = { + getCurrentLicense: jest.fn().mockResolvedValue(license), + getLatestInactiveLicense: jest.fn().mockResolvedValue(null), + }; + return { guard: new LicenseGuardService(prisma as any, licenseService as any, { isCloud: () => cloud } as any), prisma }; +} + +const starter = { plan: 'starter', status: 'active', features: { maxConnectors: 5, maxMcpServers: 3, maxUsers: 1 } }; +const trial = { plan: 'trial', status: 'active', expiresAt: new Date(Date.now() + 86_400_000), features: { maxConnectors: 15, maxMcpServers: 10, maxUsers: 3 } }; + +describe('LicenseGuardService — plan caps', () => { + const saved = process.env.PLAN_CAPS_ENFORCE; + afterEach(() => { + if (saved === undefined) delete process.env.PLAN_CAPS_ENFORCE; + else process.env.PLAN_CAPS_ENFORCE = saved; + }); + + it('keeps the trial cap on creation hard, with the PLAN_LIMIT code', async () => { + delete process.env.PLAN_CAPS_ENFORCE; + const { guard, prisma } = make(trial, { connectors: 15 }); + const err = await guard.checkCanCreateConnector('u1', 'org-1').catch((e) => e); + expect(err).toBeInstanceOf(PlanLimitException); + expect(err.getResponse()).toMatchObject({ code: 'PLAN_LIMIT', axis: 'connectors', max: 15, current: 15 }); + expect(err.message).toMatch(/Trial limit reached \(15 connectors\)/); + expect(prisma.connector.count).toHaveBeenCalledWith({ where: { organizationId: 'org-1', isActive: true, type: { not: 'FILE_SEARCH' } } }); + }); + + it('a paid plan at its cap only logs in dry-run and refuses when on', async () => { + delete process.env.PLAN_CAPS_ENFORCE; + await expect(make(starter, { connectors: 5 }).guard.checkCanCreateConnector('u1', 'org-1')).resolves.toBeUndefined(); + await expect(make(starter, { servers: 3 }).guard.checkCanCreateMcpServer('u1', 'org-1')).resolves.toBeUndefined(); + process.env.PLAN_CAPS_ENFORCE = 'on'; + await expect(make(starter, { connectors: 5 }).guard.checkCanCreateConnector('u1', 'org-1')).rejects.toThrow(/includes 5 active connectors/); + await expect(make(starter, { servers: 3 }).guard.checkCanCreateMcpServer('u1', 'org-1')).rejects.toThrow(PlanLimitException); + await expect(make(starter, { connectors: 4 }).guard.checkCanCreateConnector('u1', 'org-1')).resolves.toBeUndefined(); + }); + + it('never caps an unlimited plan or self-hosted', async () => { + process.env.PLAN_CAPS_ENFORCE = 'on'; + const enterprise = { plan: 'enterprise', status: 'active', features: { maxConnectors: null } }; + await expect(make(enterprise, { connectors: 500 }).guard.checkCanCreateConnector('u1', 'org-1')).resolves.toBeUndefined(); + await expect(make(starter, { connectors: 50 }, false).guard.checkCanCreateConnector('u1', 'org-1')).resolves.toBeUndefined(); + await expect(make(starter, { connectors: 50 }, false).guard.checkCanActivateConnector('org-1', 'REST')).resolves.toBeUndefined(); + }); + + it('refuses switching a paused connector or server back on at the cap', async () => { + process.env.PLAN_CAPS_ENFORCE = 'on'; + const err = await make(starter, { connectors: 5 }).guard.checkCanActivateConnector('org-1', 'REST').catch((e) => e); + expect(err.getResponse()).toMatchObject({ code: 'PLAN_LIMIT', axis: 'connectors', max: 5 }); + await expect(make(starter, { connectors: 4 }).guard.checkCanActivateConnector('org-1', 'REST')).resolves.toBeUndefined(); + await expect(make(starter, { servers: 3 }).guard.checkCanActivateMcpServer('org-1')).rejects.toThrow(/Pause one/); + // The trial is hard in every mode. + delete process.env.PLAN_CAPS_ENFORCE; + await expect(make(trial, { connectors: 15 }).guard.checkCanActivateConnector('org-1', 'REST')).rejects.toThrow(PlanLimitException); + }); + + it('counts a File Search folder against the File Search folders of the plan', async () => { + process.env.PLAN_CAPS_ENFORCE = 'on'; + const err = await make(starter, { fsActive: 1 }).guard.checkCanActivateConnector('org-1', 'FILE_SEARCH').catch((e) => e); + expect(err.getResponse()).toMatchObject({ axis: 'fileSearchConnectors', max: 1 }); + await expect(make(starter, { fsActive: 0 }).guard.checkCanActivateConnector('org-1', 'FILE_SEARCH')).resolves.toBeUndefined(); + }); + + it('refuses a new member above the user cap when on', async () => { + process.env.PLAN_CAPS_ENFORCE = 'on'; + await expect(make(starter, { members: 1 }).guard.checkCanAddMember('org-1')).rejects.toThrow(/includes 1 user/); + await expect(make(trial, { members: 2 }).guard.checkCanAddMember('org-1')).resolves.toBeUndefined(); + delete process.env.PLAN_CAPS_ENFORCE; + await expect(make(starter, { members: 1 }).guard.checkCanAddMember('org-1')).resolves.toBeUndefined(); + }); + + it('reports what a downgrade paused and is still paused', async () => { + const { guard, prisma } = make(starter, { connectors: 5 }); + prisma.orgSettings.findUnique.mockResolvedValue({ + value: JSON.stringify({ plan: 'starter', at: '', connectors: ['c1'], mcpServers: ['s1'], fileSearch: ['f1'] }), + }); + const usage = await guard.getUsage('u1', 'org-1'); + expect(usage.pausedByPlan).toEqual({ connectors: ['c1', 'f1'], mcpServers: ['s1'] }); + expect(prisma.orgSettings.findUnique).toHaveBeenCalledWith({ + where: { organizationId_key: { organizationId: 'org-1', key: PAUSED_BY_PLAN_KEY } }, + select: { value: true }, + }); + await expect(guard.pausedByPlanConnectorNames('org-1')).resolves.toEqual(['Name c1', 'Name f1']); + }); +}); diff --git a/packages/backend/src/license/plan-caps.ts b/packages/backend/src/license/plan-caps.ts new file mode 100644 index 000000000..892ce8ac8 --- /dev/null +++ b/packages/backend/src/license/plan-caps.ts @@ -0,0 +1,71 @@ +import { ForbiddenException } from '@nestjs/common'; + +/** + * Cloud plan caps on connectors, MCP servers and users (Team trial plan, + * 2026-11-09). How hard they are is switched by PLAN_CAPS_ENFORCE: + * + * - `dry-run` (default): nothing is refused or paused; the backend logs what + * it would do, so the numbers can be checked against the warnings first; + * - `on`: a paid plan refuses a new or re-activated connector, MCP server or + * member above its cap, and a downgrade pauses the least used items; + * - `off`: neither, and no logging. + * + * The Cloud trial's cap on creating connectors and MCP servers was hard + * before this switch existed and stays hard in every mode. + */ +export type PlanCapsMode = 'off' | 'dry-run' | 'on'; + +export function planCapsMode(env: NodeJS.ProcessEnv = process.env): PlanCapsMode { + const v = (env.PLAN_CAPS_ENFORCE ?? '').trim().toLowerCase(); + return v === 'on' || v === 'off' ? v : 'dry-run'; +} + +export type PlanCapAxis = 'connectors' | 'mcpServers' | 'users' | 'fileSearchConnectors'; + +/** The 403 the dashboard recognises (`code: 'PLAN_LIMIT'`) and turns into the swap dialog. */ +export class PlanLimitException extends ForbiddenException { + constructor(axis: PlanCapAxis, max: number, current: number, message: string) { + super({ statusCode: 403, code: 'PLAN_LIMIT', axis, max, current, message }); + } +} + +/** OrgSettings key holding what the last automatic downgrade paused. */ +export const PAUSED_BY_PLAN_KEY = 'plan_caps_paused'; + +/** What the last automatic downgrade paused, most used first. */ +export interface PausedByPlan { + plan: string; + at: string; + connectors: string[]; + mcpServers: string[]; + fileSearch: string[]; +} + +export function parsePausedByPlan(raw: string | null | undefined): PausedByPlan | null { + if (!raw) return null; + try { + const v = JSON.parse(raw); + const ids = (x: unknown) => (Array.isArray(x) ? x.filter((i): i is string => typeof i === 'string') : []); + if (!v || typeof v !== 'object') return null; + return { + plan: typeof v.plan === 'string' ? v.plan : '', + at: typeof v.at === 'string' ? v.at : '', + connectors: ids(v.connectors), + mcpServers: ids(v.mcpServers), + fileSearch: ids(v.fileSearch), + }; + } catch { + return null; + } +} + +/** + * Plans by how much they include, for telling a downgrade from an upgrade. + * The Cloud trial has the Team limits. No licence counts as below every plan. + */ +const PLAN_RANK: Record = { starter: 1, trial: 2, team: 2, business: 3, enterprise: 4 }; + +export function planRank(plan: string | null | undefined): number | null { + if (!plan) return 0; + return PLAN_RANK[plan] ?? null; +} diff --git a/packages/backend/src/mcp-server/dynamic-mcp-tools.ts b/packages/backend/src/mcp-server/dynamic-mcp-tools.ts index b2a1b29eb..7f461c38c 100644 --- a/packages/backend/src/mcp-server/dynamic-mcp-tools.ts +++ b/packages/backend/src/mcp-server/dynamic-mcp-tools.ts @@ -303,6 +303,12 @@ export class DynamicMcpTools { * blocked call gives; null when the licence is active, on self-hosted, or * when the licence cannot be read (the tools then answer for themselves). */ + /** Cloud: names of the connectors a plan change paused, or null (see LicenseGuardService). */ + async pausedByPlanConnectorNames(organizationId: string | undefined): Promise { + if (!this.deployment.isCloud() || !organizationId) return null; + return this.licenseGuard.pausedByPlanConnectorNames(organizationId).catch(() => null); + } + async trialEndedNotice( organizationId: string | undefined, userId?: string, diff --git a/packages/backend/src/mcp-server/mcp-endpoint.controller.ts b/packages/backend/src/mcp-server/mcp-endpoint.controller.ts index 38c475720..a60e033f6 100644 --- a/packages/backend/src/mcp-server/mcp-endpoint.controller.ts +++ b/packages/backend/src/mcp-server/mcp-endpoint.controller.ts @@ -640,6 +640,10 @@ export class McpEndpointController { // The workspace this connection mainly reaches: an ended trial is said // up front in anythingmcp_list_connectors, with the reactivation link. trialEnded: () => this.toolExecutor.trialEndedNotice(setupOrg || organizationId || undefined, user.sub), + pausedByPlan: async () => { + const names = await this.toolExecutor.pausedByPlanConnectorNames(setupOrg || organizationId || undefined); + return names ? { connectors: names, manageUrl: `${dashboardBase}/connectors` } : null; + }, ...(this.sharedCalls && (setupOrg || organizationId) && variant === 'shared' ? { called: ({ tool, setupTool }) => { diff --git a/packages/backend/src/mcp-server/shared-toolset.ts b/packages/backend/src/mcp-server/shared-toolset.ts index 0dee0cdde..ea3588654 100644 --- a/packages/backend/src/mcp-server/shared-toolset.ts +++ b/packages/backend/src/mcp-server/shared-toolset.ts @@ -489,6 +489,11 @@ export interface SharedToolsetDeps { * licence is active or on self-hosted. Optional, best-effort. */ trialEnded?(): Promise; + /** + * Cloud: the connectors a plan change paused (above the plan's cap), so the + * model can explain why they are missing; null when none. Best-effort. + */ + pausedByPlan?(): Promise<{ connectors: string[]; manageUrl: string } | null>; /** Where the user configures connectors, plus their servers' direct URLs. */ configuration(): Promise<{ dashboardUrl: string; @@ -887,9 +892,17 @@ export function registerSharedToolset( finishSetupUrl: deps.connectorUrl(t.connectorId), })) .sort((a, b) => a.name.localeCompare(b.name)); + const paused = await deps.pausedByPlan?.().catch(() => null); return json({ ...(await trialEndedBlock(deps)), connectors, + ...(paused?.connectors.length + ? { + pausedByPlan: paused.connectors, + pausedByPlanHint: + `These connectors are paused because the workspace's plan includes fewer active connectors; nothing was deleted. If the user asks for one of them, tell them they can choose which connectors are active at ${paused.manageUrl}, or upgrade the plan to use them all.`, + } + : {}), ...(needsSetup.length ? { needsSetup, diff --git a/packages/backend/src/mcp-servers/mcp-servers.controller.ts b/packages/backend/src/mcp-servers/mcp-servers.controller.ts index 1abb45c66..a655cd584 100644 --- a/packages/backend/src/mcp-servers/mcp-servers.controller.ts +++ b/packages/backend/src/mcp-servers/mcp-servers.controller.ts @@ -252,6 +252,10 @@ export class McpServersController { const server = await this.mcpServersService.findById(id); if (!server) throw new NotFoundException('MCP server not found'); this.assertCanWrite(server, req); + // Switching a paused server back on counts against the plan's cap (Cloud). + if (dto.isActive === true && !server.isActive) { + await this.licenseGuard.checkCanActivateMcpServer(server.organizationId); + } return this.mcpServersService.update(id, dto); } diff --git a/packages/backend/src/plan-caps/plan-caps.module.ts b/packages/backend/src/plan-caps/plan-caps.module.ts new file mode 100644 index 000000000..c18e41835 --- /dev/null +++ b/packages/backend/src/plan-caps/plan-caps.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { LicenseModule } from '../license/license.module'; +import { McpServerModule } from '../mcp-server/mcp-server.module'; +import { SettingsModule } from '../settings/settings.module'; +import { PlanCapsService } from './plan-caps.service'; + +/** + * The automatic downgrade and its undo on Cloud (PlanCapsService). Kept out + * of LicenseModule, which McpServerModule imports: it listens to plan changes + * from LicenseService and reloads tools through McpServerService. + */ +@Module({ + imports: [LicenseModule, McpServerModule, SettingsModule], + providers: [PlanCapsService], + exports: [PlanCapsService], +}) +export class PlanCapsModule {} diff --git a/packages/backend/src/plan-caps/plan-caps.service.spec.ts b/packages/backend/src/plan-caps/plan-caps.service.spec.ts new file mode 100644 index 000000000..075baf291 --- /dev/null +++ b/packages/backend/src/plan-caps/plan-caps.service.spec.ts @@ -0,0 +1,219 @@ +import { PlanCapsService } from './plan-caps.service'; +import { evaluatePlanFit, type FitItem, type FitSnapshot } from '../license/plan-fit'; +import { PAUSED_BY_PLAN_KEY, planCapsMode, parsePausedByPlan, planRank } from '../license/plan-caps'; + +const at = new Date('2026-11-01T00:00:00Z'); +const item = (id: string, calls30d: number): FitItem => ({ id, name: `Name ${id}`, calls30d, updatedAt: at }); + +function snapshot(over: Partial = {}): FitSnapshot { + return { + connectors: [], + mcpServers: [], + users: 1, + fileSearchConnectors: [], + fileSearchFiles: 0, + fileSearchBytes: 0, + codeTools: 0, + inlineCodeInUse: false, + codeRunsThisMonth: 0, + ...over, + }; +} + +function make(snap: FitSnapshot, opts: { stored?: unknown; inactive?: string[]; cloud?: boolean } = {}) { + const settings = new Map(); + if (opts.stored) settings.set(PAUSED_BY_PLAN_KEY, JSON.stringify(opts.stored)); + const inactive = new Set(opts.inactive ?? []); + const prisma = { + connector: { + updateMany: jest.fn(async () => ({ count: 0 })), + findMany: jest.fn(async ({ where }: any) => (where.id.in as string[]).filter((id) => inactive.has(id)).map((id) => ({ id }))), + }, + mcpServerConfig: { + updateMany: jest.fn(async () => ({ count: 0 })), + findMany: jest.fn(async ({ where }: any) => (where.id.in as string[]).filter((id) => inactive.has(id)).map((id) => ({ id }))), + }, + organizationMember: { + findMany: jest.fn(async () => [{ user: { email: 'admin@example.test', name: 'Ada' } }]), + }, + organization: { findUnique: jest.fn(async () => ({ name: 'Acme' })) }, + }; + const planFit = { + fit: jest.fn(async (_org: string, plan: 'starter' | 'team') => evaluatePlanFit(snap, plan)), + snapshot: jest.fn(async () => snap), + }; + const licenses = { onPlanChange: jest.fn() }; + const mcpServer = { reloadConnectorTools: jest.fn(async () => {}) }; + const orgSettings = { + get: jest.fn(async (_org: string, key: string) => settings.get(key) ?? null), + setJson: jest.fn(async (_org: string, key: string, v: unknown) => void settings.set(key, JSON.stringify(v))), + delete: jest.fn(async (_org: string, key: string) => void settings.delete(key)), + }; + const email = { sendPlanCapsAppliedEmail: jest.fn(async () => true) }; + const productEvents = { log: jest.fn(async () => {}) }; + const svc = new PlanCapsService( + prisma as any, + { isCloud: () => opts.cloud ?? true } as any, + licenses as any, + planFit as any, + mcpServer as any, + orgSettings as any, + email as any, + productEvents as any, + ); + return { svc, prisma, planFit, licenses, mcpServer, orgSettings, settings, email, productEvents }; +} + +const nine = Array.from({ length: 9 }, (_, i) => item(`c${i}`, 9 - i)); + +describe('plan-caps helpers', () => { + it('defaults to dry-run', () => { + expect(planCapsMode({})).toBe('dry-run'); + expect(planCapsMode({ PLAN_CAPS_ENFORCE: 'ON' })).toBe('on'); + expect(planCapsMode({ PLAN_CAPS_ENFORCE: 'off' })).toBe('off'); + expect(planCapsMode({ PLAN_CAPS_ENFORCE: 'yes' })).toBe('dry-run'); + }); + + it('ranks the trial with Team, and no licence below everything', () => { + expect(planRank('starter')).toBeLessThan(planRank('trial')!); + expect(planRank('trial')).toBe(planRank('team')); + expect(planRank(null)).toBe(0); + expect(planRank('mystery')).toBeNull(); + }); + + it('reads a stored record defensively', () => { + expect(parsePausedByPlan('not json')).toBeNull(); + expect(parsePausedByPlan(JSON.stringify({ connectors: ['a', 3], mcpServers: 'x' }))).toMatchObject({ + connectors: ['a'], + mcpServers: [], + fileSearch: [], + }); + }); +}); + +describe('PlanCapsService', () => { + const saved = process.env.PLAN_CAPS_ENFORCE; + afterEach(() => { + if (saved === undefined) delete process.env.PLAN_CAPS_ENFORCE; + else process.env.PLAN_CAPS_ENFORCE = saved; + }); + + it('listens to plan changes on Cloud only', () => { + const cloud = make(snapshot()); + cloud.svc.onModuleInit(); + expect(cloud.licenses.onPlanChange).toHaveBeenCalledTimes(1); + const self = make(snapshot(), { cloud: false }); + self.svc.onModuleInit(); + expect(self.licenses.onPlanChange).not.toHaveBeenCalled(); + }); + + it('trial → Starter pauses the least used above the caps, in the org only, and tells the admins', async () => { + process.env.PLAN_CAPS_ENFORCE = 'on'; + const snap = snapshot({ connectors: nine, mcpServers: [item('s1', 5), item('s2', 4), item('s3', 3), item('s4', 0)] }); + const { svc, prisma, mcpServer, settings, email, productEvents } = make(snap); + await svc.reconcile({ organizationId: 'org-1', from: 'trial', to: 'starter' }); + + expect(prisma.connector.updateMany).toHaveBeenCalledWith({ + where: { organizationId: 'org-1', id: { in: ['c5', 'c6', 'c7', 'c8'] } }, + data: { isActive: false }, + }); + expect(prisma.mcpServerConfig.updateMany).toHaveBeenCalledWith({ + where: { organizationId: 'org-1', id: { in: ['s4'] } }, + data: { isActive: false }, + }); + expect(mcpServer.reloadConnectorTools).toHaveBeenCalledTimes(4); + expect(JSON.parse(settings.get(PAUSED_BY_PLAN_KEY)!)).toMatchObject({ + plan: 'starter', + connectors: ['c5', 'c6', 'c7', 'c8'], + mcpServers: ['s4'], + }); + expect(email.sendPlanCapsAppliedEmail).toHaveBeenCalledWith( + 'admin@example.test', + expect.objectContaining({ + planName: 'Starter', + paused: { connectors: ['Name c5', 'Name c6', 'Name c7', 'Name c8'], mcpServers: ['Name s4'], fileSearch: [] }, + }), + 'org-1', + ); + expect(productEvents.log).toHaveBeenCalledWith( + expect.objectContaining({ event: 'plan_caps_applied', metadata: expect.objectContaining({ kind: 'downgrade', status: 'on', calls: 5 }) }), + ); + }); + + it('only logs in dry-run (the default)', async () => { + delete process.env.PLAN_CAPS_ENFORCE; + const { svc, prisma, email, settings, productEvents } = make(snapshot({ connectors: nine })); + await svc.reconcile({ organizationId: 'org-1', from: 'trial', to: 'starter' }); + expect(prisma.connector.updateMany).not.toHaveBeenCalled(); + expect(email.sendPlanCapsAppliedEmail).not.toHaveBeenCalled(); + expect(settings.has(PAUSED_BY_PLAN_KEY)).toBe(false); + expect(productEvents.log).toHaveBeenCalledWith( + expect.objectContaining({ metadata: expect.objectContaining({ status: 'dry-run' }) }), + ); + }); + + it('does nothing when the workspace fits, when it is off, or when the licence ends', async () => { + process.env.PLAN_CAPS_ENFORCE = 'on'; + const fits = make(snapshot({ connectors: [item('a', 1)] })); + await fits.svc.reconcile({ organizationId: 'org-1', from: 'trial', to: 'starter' }); + expect(fits.prisma.connector.updateMany).not.toHaveBeenCalled(); + + process.env.PLAN_CAPS_ENFORCE = 'off'; + const off = make(snapshot({ connectors: nine })); + await off.svc.reconcile({ organizationId: 'org-1', from: 'trial', to: 'starter' }); + expect(off.planFit.fit).not.toHaveBeenCalled(); + + process.env.PLAN_CAPS_ENFORCE = 'on'; + const ended = make(snapshot({ connectors: nine })); + await ended.svc.reconcile({ organizationId: 'org-1', from: 'starter', to: null }); + expect(ended.planFit.fit).not.toHaveBeenCalled(); + }); + + it('Starter → Team brings back what was paused, up to the Team caps', async () => { + process.env.PLAN_CAPS_ENFORCE = 'on'; + const snap = snapshot({ connectors: nine.slice(0, 5) }); + const stored = { plan: 'starter', at: '', connectors: ['c5', 'c6', 'gone'], mcpServers: ['s4'], fileSearch: [] }; + const { svc, prisma, settings, mcpServer } = make(snap, { stored, inactive: ['c5', 'c6', 's4'] }); + await svc.reconcile({ organizationId: 'org-1', from: 'starter', to: 'team' }); + expect(prisma.connector.updateMany).toHaveBeenCalledWith({ + where: { organizationId: 'org-1', id: { in: ['c5', 'c6'] } }, + data: { isActive: true }, + }); + expect(prisma.mcpServerConfig.updateMany).toHaveBeenCalledWith({ + where: { organizationId: 'org-1', id: { in: ['s4'] } }, + data: { isActive: true }, + }); + expect(mcpServer.reloadConnectorTools).toHaveBeenCalledTimes(2); + // "gone" no longer exists (or was switched on by hand), so it is dropped too. + expect(JSON.parse(settings.get(PAUSED_BY_PLAN_KEY)!).connectors).toEqual(['gone']); + }); + + it('restores only as many as the new plan has room for', async () => { + process.env.PLAN_CAPS_ENFORCE = 'on'; + const snap = snapshot({ connectors: Array.from({ length: 14 }, (_, i) => item(`a${i}`, 1)) }); + const stored = { plan: 'starter', at: '', connectors: ['p1', 'p2', 'p3'], mcpServers: [], fileSearch: [] }; + const { svc, prisma } = make(snap, { stored, inactive: ['p1', 'p2', 'p3'] }); + await svc.reconcile({ organizationId: 'org-1', from: 'starter', to: 'team' }); + expect(prisma.connector.updateMany).toHaveBeenCalledWith({ + where: { organizationId: 'org-1', id: { in: ['p1'] } }, + data: { isActive: true }, + }); + }); + + it('runs one change per workspace at a time', async () => { + process.env.PLAN_CAPS_ENFORCE = 'on'; + const { svc, planFit } = make(snapshot({ connectors: nine })); + const order: string[] = []; + planFit.fit.mockImplementation(async (_o: string, plan: any) => { + order.push(`start ${plan}`); + await new Promise((r) => setTimeout(r, 5)); + order.push(`end ${plan}`); + return evaluatePlanFit(snapshot(), plan); + }); + await Promise.all([ + svc.reconcile({ organizationId: 'org-1', from: 'trial', to: 'starter' }), + svc.reconcile({ organizationId: 'org-1', from: 'starter', to: 'team' }), + ]); + expect(order.slice(0, 2)).toEqual(['start starter', 'end starter']); + }); +}); diff --git a/packages/backend/src/plan-caps/plan-caps.service.ts b/packages/backend/src/plan-caps/plan-caps.service.ts new file mode 100644 index 000000000..193cdbf80 --- /dev/null +++ b/packages/backend/src/plan-caps/plan-caps.service.ts @@ -0,0 +1,234 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { PrismaService } from '../common/prisma.service'; +import { DeploymentService } from '../common/deployment.service'; +import { LicenseService, type PlanChange } from '../license/license.service'; +import { PlanFitService } from '../license/plan-fit.service'; +import { CLOUD_PLAN_LIMITS, isFitPlan, type FitRanked } from '../license/plan-fit'; +import { + PAUSED_BY_PLAN_KEY, + parsePausedByPlan, + planCapsMode, + planRank, + type PausedByPlan, +} from '../license/plan-caps'; +import { McpServerService } from '../mcp-server/mcp-server.service'; +import { OrgSettingsService } from '../settings/org-settings.service'; +import { EmailService } from '../settings/email.service'; +import { ProductEventService, ProductEvents } from '../audit/product-event.service'; + +/** + * The automatic downgrade and its undo (Cloud, Team trial plan 2026-11-09). + * + * When a workspace's plan changes to one with smaller caps (the Team-level + * trial or Team → Starter), the connectors, MCP servers and File Search + * folders above the new caps are paused (`isActive=false`, nothing deleted): + * the most used in the last 30 days stay active, the same ranking the + * paywall showed before checkout (PlanFitService). The admins get an email, + * the ids are kept in OrgSettings, and the dashboard marks them so the user + * can swap. When the plan grows again, the items still paused come back, up + * to the new caps. + * + * Only a plan change triggers it, so paying workspaces already above their + * cap are never touched. PLAN_CAPS_ENFORCE=dry-run (the default) only logs. + */ +@Injectable() +export class PlanCapsService implements OnModuleInit { + private readonly logger = new Logger(PlanCapsService.name); + /** One reconcile per workspace at a time; a second change waits for the first. */ + private readonly running = new Map>(); + + constructor( + private readonly prisma: PrismaService, + private readonly deployment: DeploymentService, + private readonly licenses: LicenseService, + private readonly planFit: PlanFitService, + private readonly mcpServer: McpServerService, + private readonly orgSettings: OrgSettingsService, + private readonly email: EmailService, + private readonly productEvents: ProductEventService, + ) {} + + onModuleInit(): void { + if (!this.deployment.isCloud()) return; + this.licenses.onPlanChange((change) => this.reconcile(change)); + } + + async reconcile(change: PlanChange): Promise { + const previous = this.running.get(change.organizationId) ?? Promise.resolve(); + const run = previous + .catch(() => {}) + .then(() => this.apply(change)) + .finally(() => { + if (this.running.get(change.organizationId) === run) this.running.delete(change.organizationId); + }); + this.running.set(change.organizationId, run); + return run; + } + + private async apply({ organizationId, from, to }: PlanChange): Promise { + if (!this.deployment.isCloud()) return; + const mode = planCapsMode(); + if (mode === 'off') return; + const toRank = planRank(to); + const fromRank = planRank(from); + if (toRank == null || toRank === 0) return; // unknown plan, or no licence any more + + if (isFitPlan(to)) await this.pauseOverCap(organizationId, from, to, mode === 'dry-run'); + if (fromRank != null && toRank > fromRank) await this.restore(organizationId, to!, mode === 'dry-run'); + } + + /** Pause what goes past `plan`'s caps, least used first. */ + private async pauseOverCap(organizationId: string, from: string | null, plan: 'starter' | 'team', dryRun: boolean): Promise { + const fit = await this.planFit.fit(organizationId, plan); + const connectors = fit.connectors.pause; + const servers = fit.mcpServers.pause; + const folders = fit.fileSearch.connectors.pause; + const total = connectors.length + servers.length + folders.length; + if (total === 0) return; + + const summary = `${connectors.length} connectors, ${servers.length} MCP servers, ${folders.length} File Search folders`; + await this.productEvents.log({ + event: ProductEvents.PLAN_CAPS_APPLIED, + organizationId, + metadata: { plan, kind: 'downgrade', status: dryRun ? 'dry-run' : 'on', calls: total }, + }); + if (dryRun) { + this.logger.log(`[plan caps dry-run] ${from ?? 'none'} → ${plan} for org ${organizationId}: would pause ${summary}`); + return; + } + + const connectorIds = [...connectors, ...folders].map((c) => c.id); + const serverIds = servers.map((s) => s.id); + if (connectorIds.length) { + await this.prisma.connector.updateMany({ + where: { organizationId, id: { in: connectorIds } }, + data: { isActive: false }, + }); + } + if (serverIds.length) { + await this.prisma.mcpServerConfig.updateMany({ + where: { organizationId, id: { in: serverIds } }, + data: { isActive: false }, + }); + } + await this.reloadTools(connectorIds); + + // Keep what was paused before and is still paused, so an upgrade later + // brings back everything this workspace lost to plan changes. + const earlier = parsePausedByPlan(await this.orgSettings.get(organizationId, PAUSED_BY_PLAN_KEY)); + const merge = (now: string[], before: string[] = []) => [...new Set([...now, ...before])]; + const record: PausedByPlan = { + plan, + at: new Date().toISOString(), + connectors: merge(connectors.map((c) => c.id), earlier?.connectors), + mcpServers: merge(serverIds, earlier?.mcpServers), + fileSearch: merge(folders.map((c) => c.id), earlier?.fileSearch), + }; + await this.orgSettings.setJson(organizationId, PAUSED_BY_PLAN_KEY, record); + this.logger.log(`Plan caps: ${from ?? 'none'} → ${plan} for org ${organizationId}: paused ${summary}`); + + await this.emailAdmins(organizationId, plan, fit.connectors, fit.mcpServers, fit.fileSearch.connectors).catch((err) => + this.logger.warn(`Plan caps email failed for ${organizationId}: ${err?.message ?? err}`), + ); + } + + /** Bring back what plan changes paused, up to `plan`'s caps (unlimited above Team). */ + private async restore(organizationId: string, plan: string, dryRun: boolean): Promise { + const stored = parsePausedByPlan(await this.orgSettings.get(organizationId, PAUSED_BY_PLAN_KEY)); + if (!stored) return; + const limits = isFitPlan(plan) ? CLOUD_PLAN_LIMITS[plan] : null; + const snap = await this.planFit.snapshot(organizationId); + + const stillPaused = async (ids: string[], table: 'connector' | 'mcpServerConfig') => { + if (ids.length === 0) return [] as string[]; + const rows = + table === 'connector' + ? await this.prisma.connector.findMany({ where: { organizationId, id: { in: ids }, isActive: false }, select: { id: true } }) + : await this.prisma.mcpServerConfig.findMany({ where: { organizationId, id: { in: ids }, isActive: false }, select: { id: true } }); + const found = new Set(rows.map((r) => r.id)); + return ids.filter((id) => found.has(id)); // keep the stored order + }; + const room = (max: number | null | undefined, active: number) => (max == null ? Infinity : Math.max(0, max - active)); + + const connectors = (await stillPaused(stored.connectors, 'connector')).slice(0, room(limits?.connectors, snap.connectors.length)); + const servers = (await stillPaused(stored.mcpServers, 'mcpServerConfig')).slice(0, room(limits?.mcpServers, snap.mcpServers.length)); + // File Search folders follow the File Search quota; the plan fit knows it. + const folderMax = isFitPlan(plan) ? (await this.planFit.fit(organizationId, plan)).fileSearch.connectors.max : null; + const folders = (await stillPaused(stored.fileSearch, 'connector')).slice(0, room(folderMax, snap.fileSearchConnectors.length)); + const total = connectors.length + servers.length + folders.length; + if (total === 0) return; + + await this.productEvents.log({ + event: ProductEvents.PLAN_CAPS_APPLIED, + organizationId, + metadata: { ...(isFitPlan(plan) && { plan }), kind: 'restore', status: dryRun ? 'dry-run' : 'on', calls: total }, + }); + if (dryRun) { + this.logger.log(`[plan caps dry-run] upgrade to ${plan} for org ${organizationId}: would restore ${total} items`); + return; + } + + const connectorIds = [...connectors, ...folders]; + if (connectorIds.length) { + await this.prisma.connector.updateMany({ where: { organizationId, id: { in: connectorIds } }, data: { isActive: true } }); + } + if (servers.length) { + await this.prisma.mcpServerConfig.updateMany({ where: { organizationId, id: { in: servers } }, data: { isActive: true } }); + } + await this.reloadTools(connectorIds); + + const left: PausedByPlan = { + ...stored, + connectors: stored.connectors.filter((id) => !connectors.includes(id)), + mcpServers: stored.mcpServers.filter((id) => !servers.includes(id)), + fileSearch: stored.fileSearch.filter((id) => !folders.includes(id)), + }; + if (left.connectors.length + left.mcpServers.length + left.fileSearch.length === 0) { + await this.orgSettings.delete(organizationId, PAUSED_BY_PLAN_KEY); + } else { + await this.orgSettings.setJson(organizationId, PAUSED_BY_PLAN_KEY, left); + } + this.logger.log(`Plan caps: upgrade to ${plan} for org ${organizationId}: restored ${total} items`); + } + + /** Tools of paused or restored connectors leave or join the MCP surface at once. */ + private async reloadTools(connectorIds: string[]): Promise { + for (const id of connectorIds) { + await this.mcpServer + .reloadConnectorTools(id, { notifySessions: false, syncKg: false }) + .catch((err) => this.logger.warn(`Tool reload failed for ${id}: ${err?.message ?? err}`)); + } + } + + private async emailAdmins( + organizationId: string, + plan: 'starter' | 'team', + connectors: FitRanked, + servers: FitRanked, + folders: FitRanked, + ): Promise { + const admins = await this.prisma.organizationMember.findMany({ + where: { organizationId, role: 'ADMIN', deactivatedAt: null }, + select: { user: { select: { email: true, name: true } } }, + }); + const org = await this.prisma.organization.findUnique({ where: { id: organizationId }, select: { name: true } }); + for (const { user } of admins) { + if (!user?.email) continue; + await this.email.sendPlanCapsAppliedEmail( + user.email, + { + name: user.name ?? null, + workspaceName: org?.name ?? '', + planName: plan === 'starter' ? 'Starter' : 'Team', + paused: { + connectors: connectors.pause.map((c) => c.name), + mcpServers: servers.pause.map((s) => s.name), + fileSearch: folders.pause.map((f) => f.name), + }, + kept: { connectors: connectors.keep.length, mcpServers: servers.keep.length }, + }, + organizationId, + ); + } + } +} diff --git a/packages/backend/src/settings/email-samples.ts b/packages/backend/src/settings/email-samples.ts index dbdaa9ea6..f7023c341 100644 --- a/packages/backend/src/settings/email-samples.ts +++ b/packages/backend/src/settings/email-samples.ts @@ -5,6 +5,7 @@ import { type RetentionOffer, activationReminderEmail, appApprovedEmail, + planCapsAppliedEmail, cardTrialOfferEmail, connectorAuthEmail, existingAccountEmail, @@ -106,6 +107,22 @@ export const EMAIL_SAMPLES: EmailSample[] = [ ctx, ), }, + { + id: 'plan-caps-applied', + render: (ctx, n) => + planCapsAppliedEmail( + { + name: n.name, + workspaceName: n.other, + planName: 'Starter', + paused: { connectors: ['Etsy', n.other], mcpServers: ['Support desk'], fileSearch: [] }, + kept: { connectors: 5, mcpServers: 3 }, + connectorsUrl: `${SAMPLE_CLOUD_URL}/connectors`, + planUrl: `${SAMPLE_CLOUD_URL}/settings/license`, + }, + ctx, + ), + }, { id: 'license-key', render: (ctx, n) => licenseKeyEmail({ name: n.name, licenseKey: 'AMCP-7Q2K-9XWD-4MRT-HB3N' }, ctx), diff --git a/packages/backend/src/settings/email-templates.ts b/packages/backend/src/settings/email-templates.ts index c421576c2..378451221 100644 --- a/packages/backend/src/settings/email-templates.ts +++ b/packages/backend/src/settings/email-templates.ts @@ -192,6 +192,64 @@ export function connectorAuthEmail( }); } +/** + * A plan change paused connectors, MCP servers or File Search folders above + * the new plan's caps (PlanCapsService, Cloud). Sent once per downgrade to the + * workspace admins. Transactional: it explains a change to their service. + */ +export function planCapsAppliedEmail( + input: { + name: string | null; + workspaceName: string; + /** "Starter" or "Team". */ + planName: string; + paused: { connectors: string[]; mcpServers: string[]; fileSearch: string[] }; + kept: { connectors: number; mcpServers: number }; + connectorsUrl: string; + planUrl: string; + }, + ctx: EmailBrandContext, +): RenderedEmail { + const hi = greeting(input.name); + const workspace = input.workspaceName.trim() || 'your workspace'; + const plan = input.planName; + const groups: [string, string[]][] = [ + ['Connectors', input.paused.connectors], + ['MCP servers', input.paused.mcpServers], + ['File Search folders', input.paused.fileSearch], + ]; + const listed = groups.filter(([, names]) => names.length > 0); + const total = listed.reduce((n, [, names]) => n + names.length, 0); + const listHtml = listed + .map(([label, names]) => p(`${esc(label)}: ${names.map((n) => esc(n)).join(', ')}`)) + .join(''); + const listText = listed.map(([label, names]) => `${label}: ${names.join(', ')}`).join('\n'); + const intro = + `${esc(hi)} ${esc(workspace)} is now on the ${esc(plan)} plan. ${esc(plan)} includes fewer active items than the workspace had, ` + + `so we paused the ${total === 1 ? 'one' : total} you used least in the last 30 days. Nothing was deleted, and the ones you use most keep working.`; + return build({ + subject: `${workspace} is on ${plan}: ${total} ${plural(total, 'item')} paused`, + preheader: 'Nothing was deleted. You choose which ones stay active.', + bodyHtml: + h1(`${esc(workspace)} is now on ${esc(plan)}.`, 'Nothing was deleted.') + + p(intro) + + listHtml + + p( + 'You can change which ones are active at any time: pause one under Connectors or MCP Servers, then switch on another. ' + + 'Your AI clients see only the tools of active connectors.', + ) + + button(input.connectorsUrl, 'Choose what stays active') + + small(`Need everything at once? Upgrade the plan and the paused ones come back by themselves: ${link(input.planUrl, 'see the plans')}.`), + bodyText: + `${hi}\n\n${workspace} is now on the ${plan} plan. ${plan} includes fewer active items than the workspace had, so we paused the ${total === 1 ? 'one' : total} you used least in the last 30 days. Nothing was deleted, and the ones you use most keep working.\n\n` + + `${listText}\n\n` + + 'You can change which ones are active at any time: pause one under Connectors or MCP Servers, then switch on another. Your AI clients see only the tools of active connectors.\n\n' + + `Choose what stays active: ${input.connectorsUrl}\n\n` + + `Need everything at once? Upgrade the plan and the paused ones come back by themselves: ${input.planUrl}`, + ctx, + }); +} + /** * A connector was added to the workspace from the support chat, with a * credential the user posted there (Cloud). Sent to the user who posted it diff --git a/packages/backend/src/settings/email.service.ts b/packages/backend/src/settings/email.service.ts index e25683e72..9c5dfeec5 100644 --- a/packages/backend/src/settings/email.service.ts +++ b/packages/backend/src/settings/email.service.ts @@ -20,6 +20,7 @@ import { cloudPageUrl, connectorAuthEmail, connectorAddedFromSupportEmail, + planCapsAppliedEmail, existingAccountEmail, invitationEmail, licenseKeyEmail, @@ -998,6 +999,47 @@ export class EmailService { } } + /** + * A plan change paused items above the new plan's caps (PlanCapsService). + * Transactional, to each workspace admin. False when no SMTP is configured + * or the send failed. + */ + async sendPlanCapsAppliedEmail( + to: string, + input: { + name: string | null; + workspaceName: string; + planName: string; + paused: { connectors: string[]; mcpServers: string[]; fileSearch: string[] }; + kept: { connectors: number; mcpServers: number }; + }, + organizationId?: string, + ): Promise { + const transport = await this.createTransporter(organizationId); + if (!transport) { + this.logger.warn(`Skipping plan-caps email to ${maskEmail(to)}: no SMTP configured`); + return false; + } + const base = (process.env.FRONTEND_URL || '').trim().replace(/\/+$/, '') || this.cloudUrl(); + const signIn = await this.signInProvider(to); + const email = planCapsAppliedEmail( + { + ...input, + connectorsUrl: cloudPageUrl(base, '/connectors', signIn), + planUrl: cloudPageUrl(base, '/settings/license', signIn), + }, + await this.brand(), + ); + try { + await transport.transporter.sendMail({ from: transport.from, to, ...this.mail(email) }); + this.logger.log(`Plan-caps email sent to ${maskEmail(to)}`); + return true; + } catch (err) { + this.logger.error(`Failed to send plan-caps email to ${maskEmail(to)}: ${err}`); + return false; + } + } + // ── External API Fallback ───────────────────────────────────────────────── private async sendViaExternalApi( diff --git a/packages/cli/README.md b/packages/cli/README.md index 4e3d5a49c..e5f30fadd 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -303,6 +303,13 @@ pause: Etsy (3 calls in 30 days) pause: Airtable (0 calls in 30 days) ``` +To choose yourself which connectors stay active, pause one and switch on another. At the plan's limit, switching one on without pausing another first answers `403` with `"code": "PLAN_LIMIT"`: + +```bash +amcp api PUT /api/connectors/$OLD --yes --data '{"isActive":false}' +amcp api PUT /api/connectors/$NEW --yes --data '{"isActive":true}' +``` + Without `?plan=`, it answers for Starter and Team. The connectors and MCP servers used most in the last 30 days stay active. Paused ones are never deleted. The answer also covers users, File Search (folders, files, storage) and code mode (saved code tools, inline code, runs this month). Self-hosted instances have none of these limits and answer 404. ### Limit how often a role may call a tool