Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 81 additions & 20 deletions packages/backend/src/license/license-guard.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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<void> {
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<void> {
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<LicenseUsage> {
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,
};
}
}
34 changes: 34 additions & 0 deletions packages/backend/src/license/license.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand Down Expand Up @@ -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')
Expand Down
2 changes: 2 additions & 0 deletions packages/frontend/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -26,6 +27,7 @@ export default function RootLayout({
<body>
<Providers>
<TrialBanner />
<UsageBanner />
<LicenseWall />
{children}
</Providers>
Expand Down
71 changes: 71 additions & 0 deletions packages/frontend/src/components/usage-banner.tsx
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof license.getUsage>>;

const NEXT_TIER: Record<string, string> = {
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<Usage | null>(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 (
<div className="bg-amber-500 text-amber-950 text-sm py-2 px-4 text-center">
<span>
You&apos;re using <strong>{overAxes.join(', ')}</strong> — upgrade to{' '}
<strong>{next}</strong> for higher limits.
</span>{' '}
<a
href={`${buildPricingUrl()}&utm_source=soft-warn&utm_medium=banner&utm_campaign=usage-cap`}
target="_blank"
rel="noopener noreferrer"
className="underline font-medium hover:no-underline"
>
Upgrade now
</a>{' '}
<button
type="button"
onClick={() => setDismissed(true)}
aria-label="Dismiss"
className="ml-2 inline-flex h-5 w-5 items-center justify-center rounded text-amber-950/70 hover:bg-amber-950/10 hover:text-amber-950"
>
×
</button>
</div>
);
}
8 changes: 8 additions & 0 deletions packages/frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading