From f475833b98e818de31e1413ae1c3d7f9ea551827 Mon Sep 17 00:00:00 2001 From: "helpcode.ai" Date: Mon, 9 Nov 2026 09:23:04 +0100 Subject: [PATCH 1/2] Add the plan-fit endpoint: what a workspace keeps on Starter or Team --- .../src/audit/product-event.service.ts | 4 + .../src/license/license-guard.service.spec.ts | 9 + .../src/license/license-guard.service.ts | 17 +- .../src/license/license.controller.spec.ts | 45 ++++- .../backend/src/license/license.controller.ts | 22 +++ .../backend/src/license/license.module.ts | 6 +- .../backend/src/license/plan-fit.service.ts | 90 +++++++++ packages/backend/src/license/plan-fit.spec.ts | 159 ++++++++++++++++ packages/backend/src/license/plan-fit.ts | 176 ++++++++++++++++++ .../license/reactivation.controller.spec.ts | 35 ++++ .../src/license/reactivation.controller.ts | 16 +- packages/cli/README.md | 15 ++ packages/cli/src/generated/operations.ts | 10 + 13 files changed, 597 insertions(+), 7 deletions(-) create mode 100644 packages/backend/src/license/plan-fit.service.ts create mode 100644 packages/backend/src/license/plan-fit.spec.ts create mode 100644 packages/backend/src/license/plan-fit.ts diff --git a/packages/backend/src/audit/product-event.service.ts b/packages/backend/src/audit/product-event.service.ts index fb0baf976..bcc968ee4 100644 --- a/packages/backend/src/audit/product-event.service.ts +++ b/packages/backend/src/audit/product-event.service.ts @@ -356,6 +356,10 @@ export const PAYWALL_ACTIONS = [ 'support_clicked', 'delete_account_clicked', 'left_without_subscribe', + // The "What changes with " block was shown because the workspace is + // above that plan (metadata.plan), and its list of items was opened. + 'plan_fit_shown', + 'plan_fit_details_opened', ] as const; export type PaywallAction = (typeof PAYWALL_ACTIONS)[number]; diff --git a/packages/backend/src/license/license-guard.service.spec.ts b/packages/backend/src/license/license-guard.service.spec.ts index 3ec3696e7..5ebe0c7cd 100644 --- a/packages/backend/src/license/license-guard.service.spec.ts +++ b/packages/backend/src/license/license-guard.service.spec.ts @@ -156,4 +156,13 @@ describe('LicenseGuardService.getUsage', () => { expect(usage.mcpServers).toEqual({ current: 2, max: 3, isOver: false }); expect(usage.isOverAny).toBe(true); }); + + it('counts only active connectors and servers, File Search folders apart', async () => { + const { guard, prisma } = make({ maxConnectors: 5, maxMcpServers: 3, maxUsers: 1 }); + await guard.getUsage('u1', 'org_2'); + expect(prisma.connector.count).toHaveBeenCalledWith({ + where: { organizationId: 'org_2', isActive: true, type: { not: 'FILE_SEARCH' } }, + }); + expect(prisma.mcpServerConfig.count).toHaveBeenCalledWith({ where: { organizationId: 'org_2', isActive: true } }); + }); }); diff --git a/packages/backend/src/license/license-guard.service.ts b/packages/backend/src/license/license-guard.service.ts index 8dcef023b..f6570bb39 100644 --- a/packages/backend/src/license/license-guard.service.ts +++ b/packages/backend/src/license/license-guard.service.ts @@ -50,6 +50,15 @@ export interface TrialState { cardTrialAvailable: boolean; } +/** + * The connectors and MCP servers that count against a plan's limits: active + * ones only (a paused one can be swapped back in), and File Search folders + * apart, since File Search has its own limits per plan. `PlanFitService` + * counts the same way. + */ +const PLAN_COUNTED_CONNECTOR = { isActive: true, type: { not: 'FILE_SEARCH' as const } }; +const PLAN_COUNTED_SERVER = { isActive: true }; + @Injectable() export class LicenseGuardService { constructor( @@ -113,7 +122,7 @@ export class LicenseGuardService { if (maxConnectors == null) return; const count = await this.prisma.connector.count({ - where: organizationId ? { organizationId } : { userId }, + where: { ...(organizationId ? { organizationId } : { userId }), ...PLAN_COUNTED_CONNECTOR }, }); if (count >= maxConnectors) { throw new ForbiddenException( @@ -133,7 +142,7 @@ export class LicenseGuardService { if (maxMcpServers == null) return; const count = await this.prisma.mcpServerConfig.count({ - where: organizationId ? { organizationId } : { userId }, + where: { ...(organizationId ? { organizationId } : { userId }), ...PLAN_COUNTED_SERVER }, }); if (count >= maxMcpServers) { throw new ForbiddenException( @@ -227,8 +236,8 @@ export class LicenseGuardService { const where = organizationId ? { organizationId } : userId ? { userId } : undefined; const [connectorCount, mcpCount, userCount] = await Promise.all([ - where ? this.prisma.connector.count({ where }) : Promise.resolve(0), - where ? this.prisma.mcpServerConfig.count({ where }) : Promise.resolve(0), + where ? this.prisma.connector.count({ where: { ...where, ...PLAN_COUNTED_CONNECTOR } }) : Promise.resolve(0), + where ? this.prisma.mcpServerConfig.count({ where: { ...where, ...PLAN_COUNTED_SERVER } }) : Promise.resolve(0), // Active members of the workspace, not users whose primary workspace it // is: someone invited into a second workspace keeps their first one as // User.organizationId, and a deactivated member holds no seat. diff --git a/packages/backend/src/license/license.controller.spec.ts b/packages/backend/src/license/license.controller.spec.ts index 792078aa2..059ea4f44 100644 --- a/packages/backend/src/license/license.controller.spec.ts +++ b/packages/backend/src/license/license.controller.spec.ts @@ -1,4 +1,4 @@ -import { NotFoundException } from '@nestjs/common'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; import { LicenseController } from './license.controller'; import { readEmailLinkToken } from '../common/email-link-token'; @@ -271,3 +271,46 @@ describe('LicenseController — the licence site reports a changed licence', () await expect(controller.syncFromSite(req(TOKEN), { licenseKey: KEY })).resolves.toEqual({ synced: false }); }); }); + +describe('LicenseController — plan fit', () => { + function make(cloud = true) { + const planFit = { + fit: jest.fn(async (_org: string, plan: string) => ({ plan, fits: true })), + fitAll: jest.fn(async () => ({ starter: { plan: 'starter', fits: false }, team: { plan: 'team', fits: true } })), + }; + const controller = new LicenseController( + {} as any, + {} as any, + {} as any, + {} as any, + { isCloud: () => cloud } as any, + {} as any, + {} as any, + {} as any, + undefined, + planFit as any, + ); + return { controller, planFit }; + } + const req = { user: { organizationId: 'org-1', sub: 'u1', role: 'MEMBER' } }; + + it('returns both plans for the caller\'s workspace', async () => { + const { controller, planFit } = make(); + await expect(controller.planFitForUser(req)).resolves.toEqual({ + plans: { starter: { plan: 'starter', fits: false }, team: { plan: 'team', fits: true } }, + }); + expect(planFit.fitAll).toHaveBeenCalledWith('org-1'); + }); + + it('returns one plan on request and refuses others', async () => { + const { controller, planFit } = make(); + await expect(controller.planFitForUser(req, 'team')).resolves.toEqual({ plans: { team: { plan: 'team', fits: true } } }); + expect(planFit.fit).toHaveBeenCalledWith('org-1', 'team'); + await expect(controller.planFitForUser(req, 'enterprise')).rejects.toThrow(BadRequestException); + }); + + it('is Cloud only and needs a workspace', async () => { + await expect(make(false).controller.planFitForUser(req)).rejects.toThrow(NotFoundException); + await expect(make().controller.planFitForUser({ user: {} })).rejects.toThrow(NotFoundException); + }); +}); diff --git a/packages/backend/src/license/license.controller.ts b/packages/backend/src/license/license.controller.ts index adad835ef..cfea4d204 100644 --- a/packages/backend/src/license/license.controller.ts +++ b/packages/backend/src/license/license.controller.ts @@ -14,6 +14,7 @@ import { HttpCode, Logger, Optional, + Query, } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiSecurity, ApiExcludeEndpoint } from '@nestjs/swagger'; import { timingSafeEqual } from 'crypto'; @@ -30,6 +31,8 @@ import { ProductEventService, ProductEvents } from '../audit/product-event.servi import { CardTrialEligibilityService } from './card-trial-eligibility.service'; import { CardTrialOfferService } from './card-trial-offer.service'; import { LicenseWallService, type LicenseWallView } from './license-wall.service'; +import { PlanFitService } from './plan-fit.service'; +import { isFitPlan, type FitPlan, type PlanFit } from './plan-fit'; import { createEmailLinkToken } from '../common/email-link-token'; import { CHECKOUT_BILLING_PERIODS, @@ -115,6 +118,7 @@ export class LicenseController { private readonly cardTrialEligibility: CardTrialEligibilityService, private readonly cardTrialOffer: CardTrialOfferService, @Optional() private readonly licenseWall?: LicenseWallService, + @Optional() private readonly planFit?: PlanFitService, ) {} @Get('status') @@ -483,6 +487,24 @@ export class LicenseController { return { offer }; } + @Get('plan-fit') + @UseGuards(JwtOrManagementKeyGuard) + @Throttle({ default: { limit: 30, ttl: 60_000 } }) + @ApiBearerAuth() + @ApiSecurity('api-key') + @ApiOperation({ + summary: + 'Cloud: what this workspace keeps and loses on Starter and Team (connectors, MCP servers, users, File Search, code mode), with the connectors and servers that would stay active or be paused', + }) + async planFitForUser(@Req() req: any, @Query('plan') plan?: string): Promise<{ plans: Partial> }> { + // Plan limits are a Cloud matter: self-hosted has none of these caps. + const organizationId: string | undefined = req.user?.organizationId; + if (!this.planFit || !this.deployment.isCloud() || !organizationId) throw new NotFoundException(); + if (plan !== undefined && !isFitPlan(plan)) throw new BadRequestException('plan must be starter or team'); + if (plan) return { plans: { [plan]: await this.planFit.fit(organizationId, plan) } }; + return { plans: await this.planFit.fitAll(organizationId) }; + } + @Get('wall') @UseGuards(JwtOrManagementKeyGuard) @ApiBearerAuth() diff --git a/packages/backend/src/license/license.module.ts b/packages/backend/src/license/license.module.ts index c3cc6b269..74442d5d5 100644 --- a/packages/backend/src/license/license.module.ts +++ b/packages/backend/src/license/license.module.ts @@ -10,9 +10,11 @@ import { TrialExtensionController } from './trial-extension.controller'; import { ReactivationController } from './reactivation.controller'; import { SettingsModule } from '../settings/settings.module'; import { UsersModule } from '../users/users.module'; +import { FileSearchCoreModule } from '../file-search/file-search-core.module'; +import { PlanFitService } from './plan-fit.service'; @Module({ - imports: [SettingsModule, UsersModule], + imports: [SettingsModule, UsersModule, FileSearchCoreModule], controllers: [LicenseController, TrialExtensionController, ReactivationController], providers: [ LicenseService, @@ -21,6 +23,7 @@ import { UsersModule } from '../users/users.module'; CardTrialOfferService, LicenseWallService, RetentionLicenceClient, + PlanFitService, ], exports: [ LicenseService, @@ -28,6 +31,7 @@ import { UsersModule } from '../users/users.module'; CardTrialEligibilityService, CardTrialOfferService, RetentionLicenceClient, + PlanFitService, ], }) export class LicenseModule {} diff --git a/packages/backend/src/license/plan-fit.service.ts b/packages/backend/src/license/plan-fit.service.ts new file mode 100644 index 000000000..c55547a53 --- /dev/null +++ b/packages/backend/src/license/plan-fit.service.ts @@ -0,0 +1,90 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../common/prisma.service'; +import { FileSearchRepository } from '../file-search/file-search.repository'; +import { startOfUtcMonth } from './code-run-quotas'; +import { evaluatePlanFit, FIT_PLANS, type FitItem, type FitPlan, type FitSnapshot, type PlanFit } from './plan-fit'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** + * Reads what a workspace uses today and evaluates it against the Cloud plans + * (see `plan-fit.ts`). Every query is scoped to the organization. + */ +@Injectable() +export class PlanFitService { + constructor( + private readonly prisma: PrismaService, + private readonly fileRepo: FileSearchRepository, + ) {} + + async fit(organizationId: string, plan: FitPlan, now = new Date()): Promise { + return evaluatePlanFit(await this.snapshot(organizationId, now), plan); + } + + /** Both checkout plans from one read. */ + async fitAll(organizationId: string, now = new Date()): Promise> { + const snap = await this.snapshot(organizationId, now); + return Object.fromEntries(FIT_PLANS.map((p) => [p, evaluatePlanFit(snap, p)])) as Record; + } + + async snapshot(organizationId: string, now = new Date()): Promise { + const since = new Date(now.getTime() - 30 * DAY_MS); + const [connectors, servers, users, byConnector, byServer, codeTools, runs, files] = await Promise.all([ + this.prisma.connector.findMany({ + where: { organizationId, isActive: true }, + select: { id: true, name: true, type: true, config: true, updatedAt: true }, + }), + this.prisma.mcpServerConfig.findMany({ + where: { organizationId, isActive: true }, + select: { id: true, name: true, codeMode: true, updatedAt: true }, + }), + this.prisma.organizationMember.count({ where: { organizationId, deactivatedAt: null } }), + this.prisma.toolInvocation.groupBy({ + by: ['connectorId'], + where: { organizationId, createdAt: { gte: since }, connectorId: { not: null } }, + _count: { _all: true }, + }), + this.prisma.toolInvocation.groupBy({ + by: ['mcpServerId'], + where: { organizationId, createdAt: { gte: since }, mcpServerId: { not: null } }, + _count: { _all: true }, + }), + this.prisma.$queryRaw<{ count: number }[]>` + SELECT COUNT(*)::int AS count + FROM mcp_tools t + JOIN connectors c ON c.id = t.connector_id + WHERE c.organization_id = ${organizationId} + AND t.endpoint_mapping->>'method' = 'code'`, + this.prisma.codeRun.count({ where: { organizationId, createdAt: { gte: startOfUtcMonth(now) } } }), + this.fileRepo + .withTenant(organizationId, (tx) => + tx.fileEntry.aggregate({ where: { organizationId }, _count: true, _sum: { size: true } }), + ) + .catch(() => null), + ]); + + const connectorCalls = new Map(byConnector.map((r) => [r.connectorId as string, r._count._all])); + const serverCalls = new Map(byServer.map((r) => [r.mcpServerId as string, r._count._all])); + const item = (calls: Map) => (c: { id: string; name: string; updatedAt: Date }): FitItem => ({ + id: c.id, + name: c.name, + calls30d: calls.get(c.id) ?? 0, + updatedAt: c.updatedAt, + }); + + const isFileSearch = (c: { type: unknown }) => String(c.type) === 'FILE_SEARCH'; + const isFileShare = (c: { config: unknown }) => !!(c.config as { fileShare?: unknown } | null)?.fileShare; + + return { + connectors: connectors.filter((c) => !isFileSearch(c)).map(item(connectorCalls)), + mcpServers: servers.map(item(serverCalls)), + users, + fileSearchConnectors: connectors.filter((c) => isFileSearch(c) && !isFileShare(c)).map(item(connectorCalls)), + fileSearchFiles: files?._count ?? 0, + fileSearchBytes: Number(files?._sum.size ?? 0), + codeTools: codeTools[0]?.count ?? 0, + inlineCodeInUse: servers.some((s) => s.codeMode === 'inline'), + codeRunsThisMonth: runs, + }; + } +} diff --git a/packages/backend/src/license/plan-fit.spec.ts b/packages/backend/src/license/plan-fit.spec.ts new file mode 100644 index 000000000..a143f28a8 --- /dev/null +++ b/packages/backend/src/license/plan-fit.spec.ts @@ -0,0 +1,159 @@ +import { anonymizePlanFit, evaluatePlanFit, isFitPlan, rankForCap, type FitItem, type FitSnapshot } from './plan-fit'; +import { PlanFitService } from './plan-fit.service'; + +const t0 = new Date('2026-11-01T00:00:00Z'); +const item = (id: string, calls30d: number, updatedAt = t0): FitItem => ({ id, name: `name-${id}`, calls30d, updatedAt }); + +function snapshot(over: Partial = {}): FitSnapshot { + return { + connectors: [], + mcpServers: [], + users: 1, + fileSearchConnectors: [], + fileSearchFiles: 0, + fileSearchBytes: 0, + codeTools: 0, + inlineCodeInUse: false, + codeRunsThisMonth: 0, + ...over, + }; +} + +describe('rankForCap', () => { + it('keeps the most used, then the most recently changed, and pauses the rest', () => { + const r = rankForCap( + [item('a', 1), item('b', 50), item('c', 0, new Date('2026-11-05')), item('d', 0, new Date('2026-10-01')), item('e', 9)], + 3, + ); + expect(r.keep.map((x) => x.id)).toEqual(['b', 'e', 'a']); + expect(r.pause.map((x) => x.id)).toEqual(['c', 'd']); + expect(r).toMatchObject({ current: 5, max: 3, over: true }); + }); + + it('keeps everything when unlimited or under the cap', () => { + expect(rankForCap([item('a', 0), item('b', 0)], null)).toMatchObject({ over: false, pause: [] }); + expect(rankForCap([item('a', 0)], 5)).toMatchObject({ current: 1, over: false, pause: [] }); + }); + + it('is stable on full ties', () => { + const r = rankForCap([item('z', 0), item('a', 0)], 1); + expect(r.keep[0].id).toBe('a'); + }); +}); + +describe('evaluatePlanFit', () => { + it('a workspace inside Starter fits Starter', () => { + const fit = evaluatePlanFit(snapshot({ connectors: [item('a', 3)], mcpServers: [item('s', 3)] }), 'starter'); + expect(fit.fits).toBe(true); + expect(fit.connectors).toMatchObject({ current: 1, max: 5, over: false }); + }); + + it('names what goes past Starter and fits Team', () => { + const connectors = Array.from({ length: 9 }, (_, i) => item(`c${i}`, 9 - i)); + const snap = snapshot({ + connectors, + mcpServers: [item('s1', 1), item('s2', 2), item('s3', 3), item('s4', 0)], + users: 2, + fileSearchConnectors: [item('f1', 4), item('f2', 1)], + fileSearchFiles: 1200, + fileSearchBytes: 600 * 1024 * 1024, + codeTools: 7, + inlineCodeInUse: true, + codeRunsThisMonth: 40, + }); + const starter = evaluatePlanFit(snap, 'starter'); + expect(starter.fits).toBe(false); + expect(starter.connectors.pause.map((x) => x.id)).toEqual(['c5', 'c6', 'c7', 'c8']); + expect(starter.mcpServers.pause.map((x) => x.id)).toEqual(['s4']); + expect(starter.users).toMatchObject({ current: 2, max: 1, over: true }); + expect(starter.fileSearch.connectors.pause.map((x) => x.id)).toEqual(['f2']); + expect(starter.fileSearch.files).toMatchObject({ max: 500, over: true }); + expect(starter.fileSearch.bytes.over).toBe(true); + expect(starter.code).toMatchObject({ inlineInUse: true, inlineIncluded: false, tools: { max: 5, over: true } }); + expect(starter.code.runsThisMonth).toMatchObject({ current: 40, max: 500, over: false }); + + const team = evaluatePlanFit(snap, 'team'); + expect(team.fits).toBe(true); + expect(team.connectors.pause).toEqual([]); + expect(team.code.inlineIncluded).toBe(true); + expect(team.fileSearch.fileShare).toBe(true); + }); + + it('inline code in use alone makes Starter not fit', () => { + expect(evaluatePlanFit(snapshot({ inlineCodeInUse: true }), 'starter').fits).toBe(false); + }); + + it('anonymizes names for the page without a sign-in', () => { + const fit = evaluatePlanFit(snapshot({ connectors: Array.from({ length: 6 }, (_, i) => item(`c${i}`, i)) }), 'starter'); + const anon = anonymizePlanFit(fit); + expect(anon.connectors.pause).toEqual([{ id: 'pause-0', name: '', calls30d: 0 }]); + expect(JSON.stringify(anon)).not.toContain('name-c'); + expect(anon.connectors.current).toBe(6); + }); + + it('accepts only the checkout plans', () => { + expect(isFitPlan('starter')).toBe(true); + expect(isFitPlan('team')).toBe(true); + expect(isFitPlan('enterprise')).toBe(false); + expect(isFitPlan(undefined)).toBe(false); + }); +}); + +describe('PlanFitService.snapshot', () => { + function make() { + const prisma = { + connector: { + findMany: jest.fn().mockResolvedValue([ + { id: 'c1', name: 'Shopify', type: 'REST', config: {}, updatedAt: t0 }, + { id: 'fs1', name: 'NAS', type: 'FILE_SEARCH', config: {}, updatedAt: t0 }, + { id: 'share', name: 'File Share', type: 'FILE_SEARCH', config: { fileShare: true }, updatedAt: t0 }, + ]), + }, + mcpServerConfig: { + findMany: jest.fn().mockResolvedValue([{ id: 's1', name: 'Default', codeMode: 'inline', updatedAt: t0 }]), + }, + organizationMember: { count: jest.fn().mockResolvedValue(2) }, + toolInvocation: { + groupBy: jest + .fn() + .mockResolvedValueOnce([{ connectorId: 'c1', _count: { _all: 12 } }]) + .mockResolvedValueOnce([{ mcpServerId: 's1', _count: { _all: 30 } }]), + }, + $queryRaw: jest.fn().mockResolvedValue([{ count: 4 }]), + codeRun: { count: jest.fn().mockResolvedValue(17) }, + }; + const fileRepo = { + withTenant: jest.fn((_org: string, fn: any) => + fn({ fileEntry: { aggregate: jest.fn().mockResolvedValue({ _count: 321, _sum: { size: 1024n } }) } }), + ), + }; + return { prisma, fileRepo, svc: new PlanFitService(prisma as any, fileRepo as any) }; + } + + it('reads one workspace: active items, File Search apart, File Share left out', async () => { + const { prisma, fileRepo, svc } = make(); + const snap = await svc.snapshot('org1', new Date('2026-11-09T10:00:00Z')); + expect(snap.connectors.map((c) => [c.id, c.calls30d])).toEqual([['c1', 12]]); + expect(snap.fileSearchConnectors.map((c) => c.id)).toEqual(['fs1']); + expect(snap.mcpServers[0]).toMatchObject({ id: 's1', calls30d: 30 }); + expect(snap).toMatchObject({ users: 2, codeTools: 4, inlineCodeInUse: true, codeRunsThisMonth: 17, fileSearchFiles: 321, fileSearchBytes: 1024 }); + + // Every query is scoped to the organization, and only active items count. + expect(prisma.connector.findMany.mock.calls[0][0].where).toEqual({ organizationId: 'org1', isActive: true }); + expect(prisma.mcpServerConfig.findMany.mock.calls[0][0].where).toEqual({ organizationId: 'org1', isActive: true }); + for (const [args] of prisma.toolInvocation.groupBy.mock.calls) { + expect(args.where.organizationId).toBe('org1'); + expect(args.where.createdAt.gte.toISOString()).toBe('2026-10-10T10:00:00.000Z'); + } + expect(prisma.codeRun.count.mock.calls[0][0].where.organizationId).toBe('org1'); + expect(fileRepo.withTenant.mock.calls[0][0]).toBe('org1'); + }); + + it('evaluates both plans from one read', async () => { + const { prisma, svc } = make(); + const all = await svc.fitAll('org1'); + expect(Object.keys(all)).toEqual(['starter', 'team']); + expect(all.starter.code.inlineIncluded).toBe(false); + expect(prisma.connector.findMany).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/backend/src/license/plan-fit.ts b/packages/backend/src/license/plan-fit.ts new file mode 100644 index 000000000..df6d0adb7 --- /dev/null +++ b/packages/backend/src/license/plan-fit.ts @@ -0,0 +1,176 @@ +import { quotaForPlan } from '../file-search/file-search-quotas'; +import { codeToolCapForPlan, inlineCodeAllowedForPlan } from './code-mode-quotas'; +import { monthlyCodeRunCapForPlan } from './code-run-quotas'; + +/** + * What a Cloud workspace would keep and lose on a given plan: the data behind + * the "What changes with Starter" block on the paywall, the reactivation page + * and the checkout, and the list of connectors and MCP servers the automatic + * downgrade pauses. One ranking is used for both, so the warning and the + * downgrade always name the same items. + * + * Pure functions over a snapshot of the workspace; `PlanFitService` reads the + * snapshot from the database. + */ + +/** + * The connector, MCP server and user limits of the Cloud plans a workspace can + * check out. They mirror the licence site (`lib/license.ts` there), which + * issues them as licence features; the backend needs them here to describe a + * plan the workspace does not have yet. Keep in step with the site. + */ +export const CLOUD_PLAN_LIMITS = { + starter: { connectors: 5, mcpServers: 3, users: 1 }, + team: { connectors: 15, mcpServers: 10, users: 3 }, +} as const; + +export type FitPlan = keyof typeof CLOUD_PLAN_LIMITS; +export const FIT_PLANS = Object.keys(CLOUD_PLAN_LIMITS) as FitPlan[]; + +export function isFitPlan(p: unknown): p is FitPlan { + return typeof p === 'string' && (FIT_PLANS as string[]).includes(p); +} + +/** A connector or MCP server with its tool calls over the last 30 days. */ +export interface FitItem { + id: string; + name: string; + calls30d: number; + /** For the tie-break: the most recently changed stays first. */ + updatedAt: Date; +} + +/** Everything the evaluation needs about a workspace, read once. */ +export interface FitSnapshot { + /** Active connectors that count against the plan (File Search ones excluded). */ + connectors: FitItem[]; + /** Active MCP servers. */ + mcpServers: FitItem[]; + /** Active members. */ + users: number; + /** File Search source folders (File Share not included). */ + fileSearchConnectors: FitItem[]; + fileSearchFiles: number; + fileSearchBytes: number; + codeTools: number; + /** An active MCP server with `codeMode: inline`. */ + inlineCodeInUse: boolean; + codeRunsThisMonth: number; +} + +export interface FitAxis { + current: number; + /** null = unlimited on that plan. */ + max: number | null; + over: boolean; +} + +export interface FitRanked extends FitAxis { + /** Stay active on that plan, most used first. */ + keep: { id: string; name: string; calls30d: number }[]; + /** Would be paused (never deleted), least used last. */ + pause: { id: string; name: string; calls30d: number }[]; +} + +export interface PlanFit { + plan: FitPlan; + /** Everything the workspace uses today fits the plan. */ + fits: boolean; + connectors: FitRanked; + mcpServers: FitRanked; + users: FitAxis; + fileSearch: { + connectors: FitRanked; + files: FitAxis; + bytes: FitAxis; + /** The plan includes File Share links. */ + fileShare: boolean; + }; + code: { + tools: FitAxis; + /** Inline code (`code_execute`) is switched on for a server today. */ + inlineInUse: boolean; + /** The plan includes inline code. */ + inlineIncluded: boolean; + /** Runs this calendar month against the plan's monthly cap. */ + runsThisMonth: FitAxis; + }; +} + +function axis(current: number, max: number | null): FitAxis { + return { current, max, over: max != null && current > max }; +} + +/** + * Most used first: tool calls in the last 30 days, then the most recently + * changed, then the id so the order is stable. The first `max` stay active. + */ +export function rankForCap(items: FitItem[], max: number | null): FitRanked { + const sorted = [...items].sort( + (a, b) => + b.calls30d - a.calls30d || + b.updatedAt.getTime() - a.updatedAt.getTime() || + a.id.localeCompare(b.id), + ); + const strip = ({ id, name, calls30d }: FitItem) => ({ id, name, calls30d }); + const cut = max == null ? sorted.length : Math.max(0, max); + return { + ...axis(items.length, max), + keep: sorted.slice(0, cut).map(strip), + pause: sorted.slice(cut).map(strip), + }; +} + +/** What `snapshot` keeps and loses on `plan` (Cloud). */ +export function evaluatePlanFit(snapshot: FitSnapshot, plan: FitPlan): PlanFit { + const limits = CLOUD_PLAN_LIMITS[plan]; + const fs = quotaForPlan(plan, { cloud: true }); + const connectors = rankForCap(snapshot.connectors, limits.connectors); + const mcpServers = rankForCap(snapshot.mcpServers, limits.mcpServers); + const users = axis(snapshot.users, limits.users); + const fsConnectors = rankForCap(snapshot.fileSearchConnectors, fs?.maxConnectors ?? 0); + const files = axis(snapshot.fileSearchFiles, fs ? fs.maxFiles : 0); + const bytes = axis(snapshot.fileSearchBytes, fs ? fs.maxSourceBytes : 0); + const tools = axis(snapshot.codeTools, codeToolCapForPlan(plan)); + const inlineIncluded = inlineCodeAllowedForPlan(plan); + const runs = axis(snapshot.codeRunsThisMonth, monthlyCodeRunCapForPlan(plan)); + + const fits = + !connectors.over && + !mcpServers.over && + !users.over && + !fsConnectors.over && + !files.over && + !bytes.over && + !tools.over && + !(snapshot.inlineCodeInUse && !inlineIncluded); + + return { + plan, + fits, + connectors, + mcpServers, + users, + fileSearch: { connectors: fsConnectors, files, bytes, fileShare: !!fs?.share }, + code: { tools, inlineInUse: snapshot.inlineCodeInUse, inlineIncluded, runsThisMonth: runs }, + }; +} + +/** + * The same evaluation without connector, server or folder names, for the + * reactivation page, which opens without a sign-in: it says how many would be + * paused, and the dashboard names them. + */ +export function anonymizePlanFit(fit: PlanFit): PlanFit { + const anon = (r: FitRanked): FitRanked => ({ + ...r, + keep: r.keep.map((x, i) => ({ id: `keep-${i}`, name: '', calls30d: x.calls30d })), + pause: r.pause.map((x, i) => ({ id: `pause-${i}`, name: '', calls30d: x.calls30d })), + }); + return { + ...fit, + connectors: anon(fit.connectors), + mcpServers: anon(fit.mcpServers), + fileSearch: { ...fit.fileSearch, connectors: anon(fit.fileSearch.connectors) }, + }; +} diff --git a/packages/backend/src/license/reactivation.controller.spec.ts b/packages/backend/src/license/reactivation.controller.spec.ts index 5385d5403..edf48413f 100644 --- a/packages/backend/src/license/reactivation.controller.spec.ts +++ b/packages/backend/src/license/reactivation.controller.spec.ts @@ -1,3 +1,4 @@ +import { evaluatePlanFit } from './plan-fit'; import { BadGatewayException, ConflictException, NotFoundException } from '@nestjs/common'; import { plainToInstance } from 'class-transformer'; import { validateSync } from 'class-validator'; @@ -20,6 +21,7 @@ function license(plan: string, opts: { status?: string; expiresAt?: Date | null } interface MakeOpts { + planFit?: any; cloud?: boolean; org?: { name: string } | null; current?: any; @@ -97,6 +99,7 @@ function make(opts: MakeOpts = {}) { productEvents as any, licenseWall as any, retention as any, + opts.planFit as any, ); return { controller, prisma, licenseService, licenseGuard, productEvents, licenseWall, retention }; } @@ -129,6 +132,7 @@ describe('ReactivationController', () => { usage: { connectors: 3, mcpServers: 1, users: 2 }, offerArm: null, offer: null, + planFit: null, }); expect(licenseGuard.getUsage).toHaveBeenCalledWith(undefined, 'org-1'); expect(JSON.stringify(view)).not.toMatch(/org-1|user-1|AMCP-|@/); @@ -140,6 +144,37 @@ describe('ReactivationController', () => { }); }); + it('says what changes on each plan, without connector or server names', async () => { + const at = new Date('2026-11-01T00:00:00Z'); + const snap = { + connectors: Array.from({ length: 7 }, (_, i) => ({ id: `c${i}`, name: `Secret ERP ${i}`, calls30d: i, updatedAt: at })), + mcpServers: [{ id: 's1', name: 'Private server', calls30d: 1, updatedAt: at }], + users: 1, + fileSearchConnectors: [], + fileSearchFiles: 0, + fileSearchBytes: 0, + codeTools: 0, + inlineCodeInUse: false, + codeRunsThisMonth: 0, + }; + const planFit = { + fitAll: jest.fn().mockResolvedValue({ starter: evaluatePlanFit(snap, 'starter'), team: evaluatePlanFit(snap, 'team') }), + }; + const { controller } = make({ current: license('trial', { expiresAt: ended }), planFit }); + const view = await controller.view(token()); + expect(planFit.fitAll).toHaveBeenCalledWith('org-1'); + expect(view.planFit?.starter).toMatchObject({ fits: false, connectors: { current: 7, max: 5, over: true } }); + expect(view.planFit?.starter.connectors.pause).toHaveLength(2); + expect(view.planFit?.team.fits).toBe(true); + expect(JSON.stringify(view)).not.toMatch(/Secret ERP|Private server|"c\d"|"s1"/); + }); + + it('still answers when the plan fit cannot be read', async () => { + const planFit = { fitAll: jest.fn().mockRejectedValue(new Error('db down')) }; + const { controller } = make({ current: license('trial', { expiresAt: ended }), planFit }); + await expect(controller.view(token())).resolves.toMatchObject({ state: 'trial-ended', planFit: null }); + }); + it('reads an ended trial that is no longer active from the latest inactive licence', async () => { const { controller } = make({ lastInactive: { plan: 'trial', status: 'expired', expiresAt: ended } }); const view = await controller.view(token()); diff --git a/packages/backend/src/license/reactivation.controller.ts b/packages/backend/src/license/reactivation.controller.ts index 0dec63631..47b46909a 100644 --- a/packages/backend/src/license/reactivation.controller.ts +++ b/packages/backend/src/license/reactivation.controller.ts @@ -1,3 +1,5 @@ +import { PlanFitService } from './plan-fit.service'; +import { anonymizePlanFit, type FitPlan, type PlanFit } from './plan-fit'; import { BadGatewayException, Body, @@ -59,6 +61,12 @@ export interface ReactivationView { offerArm: ReactivationOfferArm | null; /** The discounted price shown in the 'personal' arm (Cloud Starter monthly); null otherwise. */ offer: { plan: 'starter'; billingPeriod: 'monthly'; monthlyEur: number; months: number } | null; + /** + * What the workspace keeps and loses on each plan, without connector or + * server names (the page opens without a sign-in); null when it could not + * be read. + */ + planFit: Record | null; } /** The catalog adapter of the Telegram bot connector. */ @@ -131,6 +139,7 @@ export class ReactivationController { private readonly productEvents: ProductEventService, @Optional() private readonly licenseWall?: LicenseWallService, @Optional() private readonly retention?: RetentionLicenceClient, + @Optional() private readonly planFit?: PlanFitService, ) {} @Get(':token') @@ -148,11 +157,15 @@ export class ReactivationController { }); if (!org) throw new NotFoundException(INVALID_LINK); - const [{ state, trialEndedAt }, usage, client] = await Promise.all([ + const [{ state, trialEndedAt }, usage, client, fits] = await Promise.all([ this.stateOf(organizationId), this.licenseGuard.getUsage(undefined, organizationId), this.clientOf(organizationId).catch(() => null), + this.planFit ? this.planFit.fitAll(organizationId).catch(() => null) : Promise.resolve(null), ]); + const planFit = fits + ? { starter: anonymizePlanFit(fits.starter), team: anonymizePlanFit(fits.team) } + : null; const offerArm = await this.offerArmOf(organizationId, state).catch(() => null); await this.recordView(organizationId, state, offerArm); return { @@ -175,6 +188,7 @@ export class ReactivationController { mcpServers: usage.mcpServers.current, users: usage.users.current, }, + planFit, }; } diff --git a/packages/cli/README.md b/packages/cli/README.md index 69465005a..4e3d5a49c 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -56,6 +56,7 @@ With npm, it requires Node.js 22.12 or newer. The examples below use real comman - [Stand up a workspace in one script](#stand-up-a-workspace-in-one-script) - [Turn your own API into MCP tools](#turn-your-own-api-into-mcp-tools) - [See what your agents did](#see-what-your-agents-did) + - [Check what a plan change would pause (Cloud)](#check-what-a-plan-change-would-pause-cloud) - [Command reference](#command-reference) - [Management API (`amcp api`)](#management-api-amcp-api) - [Shell completion](#shell-completion) @@ -290,6 +291,20 @@ $ amcp api GET "/api/audit/invocations?status=ERROR&limit=3" \ `GET /api/audit/breakdowns?days=30` returns usage and cost by connector, MCP server and user. +### Check what a plan change would pause (Cloud) + +On AnythingMCP Cloud, each plan includes a number of active connectors, MCP servers and users, plus File Search and code-mode limits. Before moving to a smaller plan, ask what fits. A read-only management key is enough: + +```console +$ amcp api GET "/api/license/plan-fit?plan=starter" \ + | jq -r '.plans.starter | "fits: \(.fits)", (.connectors.pause[] | "pause: \(.name) (\(.calls30d) calls in 30 days)")' +fits: false +pause: Etsy (3 calls in 30 days) +pause: Airtable (0 calls in 30 days) +``` + +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 A role grants tools to its members. Each grant can also carry a limit per minute and per hour. The limit counts per user and tool, across all of that user's API keys and sessions. Workspace admins are never limited. Setting limits needs an admin management key: diff --git a/packages/cli/src/generated/operations.ts b/packages/cli/src/generated/operations.ts index 6f511141c..0b26c6464 100644 --- a/packages/cli/src/generated/operations.ts +++ b/packages/cli/src/generated/operations.ts @@ -1608,6 +1608,16 @@ export const OPERATIONS: readonly ApiOperation[] = [ "pathParams": [], "hasBody": true }, + { + "resource": "license", + "action": "planFitForUser", + "method": "GET", + "path": "/api/license/plan-fit", + "summary": "Cloud: what this workspace keeps and loses on Starter and Team (connectors, MCP servers, users, File Search, code mode), with the connectors and servers that would stay active or be paused", + "admin": false, + "pathParams": [], + "hasBody": false + }, { "resource": "license", "action": "pricingLinkToken", From 90cdec88a1629e4a0a929b3e243341b9e0d2e663 Mon Sep 17 00:00:00 2001 From: "helpcode.ai" Date: Mon, 9 Nov 2026 09:34:39 +0100 Subject: [PATCH 2/2] Avoid a computed key from the query in plan-fit --- packages/backend/src/license/license.controller.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/license/license.controller.ts b/packages/backend/src/license/license.controller.ts index cfea4d204..d7e149f6a 100644 --- a/packages/backend/src/license/license.controller.ts +++ b/packages/backend/src/license/license.controller.ts @@ -501,7 +501,8 @@ export class LicenseController { const organizationId: string | undefined = req.user?.organizationId; if (!this.planFit || !this.deployment.isCloud() || !organizationId) throw new NotFoundException(); if (plan !== undefined && !isFitPlan(plan)) throw new BadRequestException('plan must be starter or team'); - if (plan) return { plans: { [plan]: await this.planFit.fit(organizationId, plan) } }; + if (plan === 'starter') return { plans: { starter: await this.planFit.fit(organizationId, 'starter') } }; + if (plan === 'team') return { plans: { team: await this.planFit.fit(organizationId, 'team') } }; return { plans: await this.planFit.fitAll(organizationId) }; }