From 52deea37cb28e962e3e007b4afd2b807e4cbaff2 Mon Sep 17 00:00:00 2001 From: Matteo Date: Thu, 21 May 2026 15:51:02 +0200 Subject: [PATCH] license: soft-warn cap policy + /usage endpoint + UsageBanner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Team plan-shaped quota model and switches paid-tier connector / MCP-server caps from hard 403 to a soft upgrade nudge. Trial still has a hard cap (it's a sales tool, abuse risk). New surface: - GET /api/license/usage → { plan, connectors{current,max,isOver}, mcpServers{...}, users{...}, isOverAny } - frontend UsageBanner mounted in app/layout.tsx, fetches /usage, renders an amber banner above the dashboard when any axis is over, with a localized "Upgrade to {next-tier}" CTA + utm-tagged pricing link. Dismissible per session. The website's license.features map already carries maxConnectors, maxMcpServers and maxUsers — no new contract bytes needed, just a new endpoint that joins those with Prisma counts. Backend changes: - LicenseGuardService.checkCanCreateConnector / -McpServer: only block when license.plan === 'trial'. Paid tiers go through silently — the banner does the work. - New LicenseGuardService.getUsage(userId, organizationId): joins Prisma counts (connector, mcpServerConfig, user) with the features payload to return UsageCap[]s. - LicenseController: new @Get('/usage') with optional JWT auth, same shape as /status — anonymous self-hosted single-user instances still get their global counts. Frontend changes: - New UsageBanner client component (similar shape to TrialBanner) - license.getUsage() added to lib/api.ts - Mounted in app/layout.tsx right after TrialBanner Compatible with the website-side plan: lib/license.ts carries Starter caps 5/3, Team 15/10, Business unlimited; backend doesn't enforce these values, only reports them. --- .../src/license/license-guard.service.ts | 101 ++++++++++++++---- .../backend/src/license/license.controller.ts | 34 ++++++ packages/frontend/src/app/layout.tsx | 2 + .../frontend/src/components/usage-banner.tsx | 71 ++++++++++++ packages/frontend/src/lib/api.ts | 8 ++ 5 files changed, 196 insertions(+), 20 deletions(-) create mode 100644 packages/frontend/src/components/usage-banner.tsx diff --git a/packages/backend/src/license/license-guard.service.ts b/packages/backend/src/license/license-guard.service.ts index d8d2933..c4273c4 100644 --- a/packages/backend/src/license/license-guard.service.ts +++ b/packages/backend/src/license/license-guard.service.ts @@ -3,6 +3,23 @@ import { PrismaService } from '../common/prisma.service'; import { LicenseService } from './license.service'; import { DeploymentService } from '../common/deployment.service'; +export interface UsageCap { + current: number; + max: number | null; + isOver: boolean; +} + +export interface LicenseUsage { + plan: string | null; + 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). + isOverAny: boolean; +} + @Injectable() export class LicenseGuardService { constructor( @@ -36,41 +53,85 @@ 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. + */ 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) { - const count = await this.prisma.connector.count({ - where: organizationId ? { organizationId } : { userId }, - }); - if (count >= maxConnectors) { - throw new ForbiddenException( - `You have reached the maximum of ${maxConnectors} connectors on your current plan. Upgrade at anythingmcp.com/pricing`, - ); - } + if (maxConnectors == null) return; + + const count = await this.prisma.connector.count({ + where: organizationId ? { organizationId } : { userId }, + }); + if (count >= maxConnectors) { + throw new ForbiddenException( + `Trial limit reached (${maxConnectors} connectors). Upgrade at anythingmcp.com/pricing`, + ); } } 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) { - const count = await this.prisma.mcpServerConfig.count({ - where: organizationId ? { organizationId } : { userId }, - }); - if (count >= maxMcpServers) { - throw new ForbiddenException( - `You have reached the maximum of ${maxMcpServers} MCP servers on your current plan. Upgrade at anythingmcp.com/pricing`, - ); - } + if (maxMcpServers == null) return; + + const count = await this.prisma.mcpServerConfig.count({ + where: organizationId ? { organizationId } : { userId }, + }); + if (count >= maxMcpServers) { + throw new ForbiddenException( + `Trial limit reached (${maxMcpServers} MCP servers). Upgrade at anythingmcp.com/pricing`, + ); } } + + /** + * Report current usage and caps so the frontend can render an upgrade + * nudge. Used by GET /license/usage. + */ + async getUsage(userId?: string, organizationId?: string): Promise { + const license = await this.licenseService.getCurrentLicense(organizationId); + const features = (license?.features as any) ?? {}; + 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), + organizationId + ? this.prisma.user.count({ where: { organizationId } }) + : Promise.resolve(userId ? 1 : 0), + ]); + + const wrap = (current: number, max: number | null | undefined): UsageCap => ({ + current, + max: max ?? null, + isOver: max != null && current > max, + }); + + const connectors = wrap(connectorCount, features.maxConnectors); + const mcpServers = wrap(mcpCount, features.maxMcpServers); + const users = wrap(userCount, features.maxUsers); + + return { + plan: license?.plan ?? null, + connectors, + mcpServers, + users, + isOverAny: connectors.isOver || mcpServers.isOver || users.isOver, + }; + } } diff --git a/packages/backend/src/license/license.controller.ts b/packages/backend/src/license/license.controller.ts index 61422ca..a2c4886 100644 --- a/packages/backend/src/license/license.controller.ts +++ b/packages/backend/src/license/license.controller.ts @@ -15,6 +15,7 @@ import { AuthGuard } from '@nestjs/passport'; import { IsString, Matches } from 'class-validator'; import { Roles, RolesGuard } from '../auth/roles.guard'; import { LicenseService } from './license.service'; +import { LicenseGuardService } from './license-guard.service'; import { AuthService } from '../auth/auth.service'; import { UsersService } from '../users/users.service'; import { DeploymentService } from '../common/deployment.service'; @@ -34,6 +35,7 @@ export class LicenseController { constructor( private readonly licenseService: LicenseService, + private readonly licenseGuard: LicenseGuardService, private readonly authService: AuthService, private readonly usersService: UsersService, private readonly deployment: DeploymentService, @@ -85,6 +87,38 @@ export class LicenseController { return { instanceId }; } + @Get('usage') + @ApiOperation({ + summary: + 'Current usage vs caps for the org. Drives the soft-warn upgrade banner in the UI.', + }) + async getUsage(@Req() req: any) { + // Optional auth — anonymous self-hosted single-user instances still get + // their global usage. Cloud requires an org-scoped JWT. + let organizationId: string | undefined; + let userId: string | undefined; + const authHeader = req.headers?.authorization; + if (authHeader?.startsWith('Bearer ')) { + try { + const payload = this.authService.verifyToken(authHeader.substring(7)); + organizationId = payload.organizationId ?? undefined; + userId = payload.sub ?? undefined; + } catch {} + } + + if (this.deployment.isCloud() && !organizationId) { + return { + plan: null, + connectors: { current: 0, max: null, isOver: false }, + mcpServers: { current: 0, max: null, isOver: false }, + users: { current: 0, max: null, isOver: false }, + isOverAny: false, + }; + } + + return this.licenseGuard.getUsage(userId, organizationId); + } + @Put('key') @UseGuards(AuthGuard('jwt'), RolesGuard) @Roles('ADMIN') diff --git a/packages/frontend/src/app/layout.tsx b/packages/frontend/src/app/layout.tsx index 5eec5dd..8a8b741 100644 --- a/packages/frontend/src/app/layout.tsx +++ b/packages/frontend/src/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from 'next'; import './globals.css'; import { Providers } from './providers'; import { TrialBanner } from '@/components/trial-banner'; +import { UsageBanner } from '@/components/usage-banner'; import { LicenseWall } from '@/components/license-wall'; export const metadata: Metadata = { @@ -26,6 +27,7 @@ export default function RootLayout({ + {children} diff --git a/packages/frontend/src/components/usage-banner.tsx b/packages/frontend/src/components/usage-banner.tsx new file mode 100644 index 0000000..c87a851 --- /dev/null +++ b/packages/frontend/src/components/usage-banner.tsx @@ -0,0 +1,71 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { license } from '@/lib/api'; +import { useAuth } from '@/lib/auth-context'; +import { buildPricingUrl } from '@/lib/marketing'; + +type Usage = Awaited>; + +const NEXT_TIER: Record = { + starter: 'Team', + team: 'Business', + // No nudge for business/enterprise — they're at unlimited or near it. +}; + +/** + * Soft-warn upgrade nudge. Renders when the current org is over any cap + * (connectors, MCP servers, or users) AND a higher tier exists. Non-blocking + * — the user can keep working; this just suggests an upgrade. Caps are + * advisory by product decision (May 2026). + */ +export function UsageBanner() { + const { token } = useAuth(); + const [usage, setUsage] = useState(null); + const [dismissed, setDismissed] = useState(false); + + useEffect(() => { + if (!token) return; + license.getUsage(token).then(setUsage).catch(() => {}); + }, [token]); + + if (!usage || !usage.plan || !usage.isOverAny || dismissed) return null; + const next = NEXT_TIER[usage.plan]; + if (!next) return null; + + const overAxes: string[] = []; + if (usage.connectors.isOver) { + overAxes.push(`${usage.connectors.current}/${usage.connectors.max} connectors`); + } + if (usage.mcpServers.isOver) { + overAxes.push(`${usage.mcpServers.current}/${usage.mcpServers.max} MCP servers`); + } + if (usage.users.isOver) { + overAxes.push(`${usage.users.current}/${usage.users.max} users`); + } + + return ( +
+ + You're using {overAxes.join(', ')} — upgrade to{' '} + {next} for higher limits. + {' '} + + Upgrade now + {' '} + +
+ ); +} diff --git a/packages/frontend/src/lib/api.ts b/packages/frontend/src/lib/api.ts index 5b25097..6ac1fef 100644 --- a/packages/frontend/src/lib/api.ts +++ b/packages/frontend/src/lib/api.ts @@ -366,6 +366,14 @@ export const license = { }), getInstanceId: () => request<{ instanceId: string }>('/api/license/instance-id'), + getUsage: (token?: string) => + request<{ + plan: string | null; + connectors: { current: number; max: number | null; isOver: boolean }; + mcpServers: { current: number; max: number | null; isOver: boolean }; + users: { current: number; max: number | null; isOver: boolean }; + isOverAny: boolean; + }>('/api/license/usage', { token }), }; // MCP Servers