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