diff --git a/packages/backend/src/audit/product-event.service.ts b/packages/backend/src/audit/product-event.service.ts index 727f3e24..0c628b95 100644 --- a/packages/backend/src/audit/product-event.service.ts +++ b/packages/backend/src/audit/product-event.service.ts @@ -24,6 +24,12 @@ export const ProductEvents = { API_KEY_GENERATED: 'api_key_generated', /** Left the post-attach page having copied nothing at all. */ LEFT_WITHOUT_COPY: 'left_page_without_copy', + /** + * Watched the first MCP request arrive live on the connect page (the + * connection check turned green). Answers: does the live check help people + * finish connecting, read against post_attach_viewed. + */ + FIRST_CALL_SEEN: 'first_call_seen', /** Saw the starter pack on /welcome. Read against the next one: how many take it. */ STARTER_PACK_VIEWED: 'starter_pack_viewed', /** Installed connectors from the starter pack. metadata.adapterSlug = comma list. */ diff --git a/packages/backend/src/auth/auth.controller.spec.ts b/packages/backend/src/auth/auth.controller.spec.ts index 5c78a361..83f2f9fa 100644 --- a/packages/backend/src/auth/auth.controller.spec.ts +++ b/packages/backend/src/auth/auth.controller.spec.ts @@ -23,10 +23,16 @@ function makeController({ mode, accounts = [], openRegistration = true, + orgLicense = null, + orgEndedLicense = null, }: { mode: 'cloud' | 'self-hosted'; accounts?: Account[]; openRegistration?: boolean; + /** The workspace's active licence (cloud), if any. */ + orgLicense?: { plan: string } | null; + /** Its latest ended licence (cloud), if any. */ + orgEndedLicense?: { plan: string; status: string } | null; }) { const users = [...accounts]; const sent: string[] = []; @@ -118,7 +124,10 @@ function makeController({ configService as any, siteSettings as any, organizationsService as any, - {} as any, // licenseService + { + getCurrentLicense: jest.fn(async () => orgLicense), + getLatestInactiveLicense: jest.fn(async () => orgEndedLicense), + } as any, // licenseService {} as any, // securityEvents {} as any, // rolesService {} as any, // recoveryCodes @@ -311,3 +320,33 @@ describe('AuthController — answers that do not reveal accounts', () => { }); }); }); + +/** + * Every cloud admin used to be told they "need licence setup" at each sign-in + * (the check read an instance-wide key that cloud never writes), and the app + * showed "Trial Activated!" to paying workspaces. Only a workspace without any + * licence needs setup now. + */ +describe('AuthController.login — cloud licence setup', () => { + const login = (controller: any) => + controller.login({}, { email: 'taken@example.com', password: 'Correct#Horse1' }); + + it('does not ask a workspace with an active licence to set one up', async () => { + const { controller } = makeController({ mode: 'cloud', accounts: [EXISTING], orgLicense: { plan: 'team' } }); + expect((await login(controller)).needsLicenseSetup).toBeUndefined(); + }); + + it('does not ask a workspace whose trial ended (the licence wall offers the plans)', async () => { + const { controller } = makeController({ + mode: 'cloud', + accounts: [EXISTING], + orgEndedLicense: { plan: 'trial', status: 'expired' }, + }); + expect((await login(controller)).needsLicenseSetup).toBeUndefined(); + }); + + it('asks a workspace that never had a licence', async () => { + const { controller } = makeController({ mode: 'cloud', accounts: [EXISTING] }); + expect((await login(controller)).needsLicenseSetup).toBe(true); + }); +}); diff --git a/packages/backend/src/auth/auth.controller.ts b/packages/backend/src/auth/auth.controller.ts index 0c082584..84f4436a 100644 --- a/packages/backend/src/auth/auth.controller.ts +++ b/packages/backend/src/auth/auth.controller.ts @@ -399,12 +399,24 @@ export class AuthController { // Check if ADMIN needs to complete license setup let needsLicenseSetup = false; if (user.role === 'ADMIN') { - const licenseKey = await this.siteSettings.get('license_key'); - // Self-hosted: once the Business trial has been started the choice has - // been made, and the chooser would only offer a trial that cannot start. const isCloud = this.configService.get('DEPLOYMENT_MODE') === 'cloud'; - if (!licenseKey && (isCloud || (await this.edition.getState()).trialAvailable)) { - needsLicenseSetup = true; + if (isCloud) { + // Cloud licences belong to the workspace; the instance-wide + // `license_key` setting is never written there. Reading it made every + // cloud admin "need licence setup" at every sign-in, and the app then + // showed "Trial Activated!" to paying customers ("0 days") and again + // to users mid-trial. Only a workspace with no licence at all needs it. + const orgId = user.organizationId ?? undefined; + const current = orgId ? await this.licenseService.getCurrentLicense(orgId) : null; + const ended = !current && orgId ? await this.licenseService.getLatestInactiveLicense(orgId) : null; + needsLicenseSetup = !current && !ended; + } else { + const licenseKey = await this.siteSettings.get('license_key'); + // Self-hosted: once the Business trial has been started the choice has + // been made, and the chooser would only offer a trial that cannot start. + if (!licenseKey && (await this.edition.getState()).trialAvailable) { + needsLicenseSetup = true; + } } } diff --git a/packages/backend/src/auth/email-verified.guard.spec.ts b/packages/backend/src/auth/email-verified.guard.spec.ts new file mode 100644 index 00000000..7c9206fa --- /dev/null +++ b/packages/backend/src/auth/email-verified.guard.spec.ts @@ -0,0 +1,62 @@ +import { ForbiddenException } from '@nestjs/common'; +import { EmailVerifiedGuard } from './email-verified.guard'; + +/** + * Global guards run before the route's AuthGuard('jwt'), so req.user is unset + * here. The guard used to return early on that and checked nothing. + */ +describe('EmailVerifiedGuard', () => { + function makeGuard(opts: { cloud?: boolean; verified?: boolean }) { + const prisma = { + user: { findUnique: jest.fn(async () => ({ emailVerified: opts.verified ?? false })) }, + }; + const auth = { + verifyToken: jest.fn((t: string) => { + if (t === 'good') return { sub: 'u1' }; + throw new Error('invalid'); + }), + }; + const guard = new EmailVerifiedGuard( + { get: () => (opts.cloud === false ? 'self-hosted' : 'cloud') } as any, + prisma as any, + auth as any, + ); + return { guard, prisma }; + } + const ctx = (req: any) => ({ switchToHttp: () => ({ getRequest: () => req }) }) as any; + + it('refuses an unverified cloud user identified only by the bearer token', async () => { + const { guard } = makeGuard({ verified: false }); + await expect( + guard.canActivate(ctx({ path: '/api/mcp-servers', headers: { authorization: 'Bearer good' } })), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('lets a verified user through', async () => { + const { guard } = makeGuard({ verified: true }); + await expect( + guard.canActivate(ctx({ path: '/api/mcp-servers', headers: { authorization: 'Bearer good' } })), + ).resolves.toBe(true); + }); + + it('keeps the verification endpoints open to the unverified user', async () => { + const { guard } = makeGuard({ verified: false }); + await expect( + guard.canActivate(ctx({ path: '/api/auth/verify-email', headers: { authorization: 'Bearer good' } })), + ).resolves.toBe(true); + }); + + it('leaves tokens it cannot read, and anonymous requests, to the route', async () => { + const { guard, prisma } = makeGuard({ verified: false }); + await expect(guard.canActivate(ctx({ path: '/mcp/x', headers: { authorization: 'Bearer other' } }))).resolves.toBe(true); + await expect(guard.canActivate(ctx({ path: '/api/adapters', headers: {} }))).resolves.toBe(true); + expect(prisma.user.findUnique).not.toHaveBeenCalled(); + }); + + it('does nothing on self-hosted', async () => { + const { guard } = makeGuard({ cloud: false, verified: false }); + await expect( + guard.canActivate(ctx({ path: '/api/mcp-servers', headers: { authorization: 'Bearer good' } })), + ).resolves.toBe(true); + }); +}); diff --git a/packages/backend/src/auth/email-verified.guard.ts b/packages/backend/src/auth/email-verified.guard.ts index 26027980..e8887e9f 100644 --- a/packages/backend/src/auth/email-verified.guard.ts +++ b/packages/backend/src/auth/email-verified.guard.ts @@ -1,6 +1,7 @@ import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { PrismaService } from '../common/prisma.service'; +import { AuthService } from './auth.service'; /** * In cloud mode, users must verify their email before they can access any @@ -8,6 +9,11 @@ import { PrismaService } from '../common/prisma.service'; * * Applied globally via APP_GUARD. The allowlist below covers the endpoints * needed to *complete* the verification flow (and to log out). + * + * Global guards run before a route's own AuthGuard('jwt'), so `req.user` is + * not set yet when this one runs. It used to return early on that and never + * checked anything: an unverified cloud account could call every endpoint. + * It now reads the session token itself. */ @Injectable() export class EmailVerifiedGuard implements CanActivate { @@ -27,15 +33,30 @@ export class EmailVerifiedGuard implements CanActivate { constructor( private readonly configService: ConfigService, private readonly prisma: PrismaService, + private readonly authService: AuthService, ) {} + /** The session's user id: from req.user when set, else from the bearer JWT. */ + private userIdOf(req: any): string | undefined { + if (req?.user?.sub) return req.user.sub; + const header = req?.headers?.authorization; + if (typeof header !== 'string' || !header.startsWith('Bearer ')) return undefined; + try { + return this.authService.verifyToken(header.slice(7)).sub || undefined; + } catch { + // Not a session token of ours (an MCP OAuth token, an expired JWT): + // the route's own authentication deals with it. + return undefined; + } + } + async canActivate(context: ExecutionContext): Promise { const isCloud = this.configService.get('DEPLOYMENT_MODE') === 'cloud'; if (!isCloud) return true; const req = context.switchToHttp().getRequest(); - const user = req?.user; - if (!user?.sub) return true; + const userId = this.userIdOf(req); + if (!userId) return true; const path: string = req.path || req.url || ''; if (EmailVerifiedGuard.PATH_ALLOWLIST.some((p) => path.startsWith(p))) { @@ -43,7 +64,7 @@ export class EmailVerifiedGuard implements CanActivate { } const dbUser = await this.prisma.user.findUnique({ - where: { id: user.sub }, + where: { id: userId }, select: { emailVerified: true }, }); if (!dbUser?.emailVerified) { diff --git a/packages/backend/src/license/license-checkout.spec.ts b/packages/backend/src/license/license-checkout.spec.ts new file mode 100644 index 00000000..4a6f257c --- /dev/null +++ b/packages/backend/src/license/license-checkout.spec.ts @@ -0,0 +1,353 @@ +/** + * The card-trial checkout link (Cloud only). + * + * A trial user is offered Stripe Checkout with a trial that ends when their + * free trial would have. The licence site builds the checkout; this server + * vouches for who is buying. These tests pin what must never drift: the + * email and workspace come from the session, the trial end is the free + * trial's own, self-hosted never sees the route, only admins can open it, a + * paying workspace is not sold a second subscription, and upstream failures + * reach the browser as one clean 502. + */ +import axios from 'axios'; +import { + BadGatewayException, + ConflictException, + ExecutionContext, + ForbiddenException, + NotFoundException, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { RolesGuard } from '../auth/roles.guard'; +import { LicenseController } from './license.controller'; +import { LicenseService } from './license.service'; +import { + CheckoutUnavailableError, + cardTrialEnd, + checkoutAdMetadata, + cloudFrontendOrigin, +} from './license-checkout'; + +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; + +const NOW = new Date('2026-10-01T10:00:00.000Z'); +const IN_FIVE_DAYS = new Date('2026-10-06T10:00:00.000Z'); + +function trialLicense(overrides: Record = {}) { + return { + licenseKey: 'AMCP-TRIA-L000-0000-0001', + plan: 'trial', + status: 'active', + features: null, + expiresAt: IN_FIVE_DAYS, + lastVerifiedAt: null, + instanceId: 'instance-1', + ...overrides, + }; +} + +describe('cardTrialEnd', () => { + it('ends the card trial exactly when the free trial ends', () => { + expect(cardTrialEnd(trialLicense(), NOW)?.toISOString()).toBe(IN_FIVE_DAYS.toISOString()); + }); + + it('answers null (pay now) once the free trial is over', () => { + expect(cardTrialEnd(trialLicense({ expiresAt: new Date(NOW.getTime() - 1000) }), NOW)).toBeNull(); + expect(cardTrialEnd(trialLicense({ expiresAt: NOW }), NOW)).toBeNull(); + }); + + it('answers null (pay now) with under 48 hours of the free trial left, rather than lengthening it', () => { + expect(cardTrialEnd(trialLicense({ expiresAt: new Date(NOW.getTime() + 47 * 3_600_000) }), NOW)).toBeNull(); + expect(cardTrialEnd(trialLicense({ expiresAt: new Date(NOW.getTime() + 49 * 3_600_000) }), NOW)).not.toBeNull(); + }); + + it('answers null for anything that is not a live trial', () => { + expect(cardTrialEnd(null, NOW)).toBeNull(); + expect(cardTrialEnd(trialLicense({ plan: 'cloud_team' }) as any, NOW)).toBeNull(); + expect(cardTrialEnd(trialLicense({ status: 'expired' }) as any, NOW)).toBeNull(); + expect(cardTrialEnd(trialLicense({ expiresAt: null }) as any, NOW)).toBeNull(); + }); +}); + +describe('checkoutAdMetadata', () => { + it('passes a consented click id and nothing else', () => { + expect( + checkoutAdMetadata({ gclid: 'abc', ad_consent: 'granted', captured_at: '2026-09-01T00:00:00Z' }), + ).toEqual({ ad_consent: 'granted', gclid: 'abc' }); + }); + + it('sends nothing without consent or without an id', () => { + expect(checkoutAdMetadata(null)).toBeUndefined(); + expect(checkoutAdMetadata({ gclid: 'abc', ad_consent: 'denied' } as any)).toBeUndefined(); + expect(checkoutAdMetadata({ ad_consent: 'granted' })).toBeUndefined(); + }); +}); + +describe('cloudFrontendOrigin', () => { + it('uses FRONTEND_URL without a trailing slash', () => { + expect(cloudFrontendOrigin({ FRONTEND_URL: 'https://cloud.example.com/' } as any)).toBe( + 'https://cloud.example.com', + ); + }); + + it('falls back to the public cloud URL', () => { + expect(cloudFrontendOrigin({} as any)).toBe('https://cloud.anythingmcp.com'); + }); +}); + +describe('LicenseService.createCheckoutIntent', () => { + const originalToken = process.env.LICENSE_SERVICE_TOKEN; + const payload = { + email: 'admin@example.com', + plan: 'cloud_team' as const, + billingPeriod: 'monthly' as const, + returnUrl: 'https://cloud.example.com/settings/license/activate', + organizationId: 'org-1', + }; + + function makeService() { + const siteSettings = { get: jest.fn(async () => null), set: jest.fn() }; + const deployment = { isCloud: () => true, isSelfHosted: () => false }; + return new LicenseService({} as any, siteSettings as any, deployment as any); + } + + function httpError(status?: number, data?: any) { + return { + response: status === undefined ? undefined : { status, data }, + message: `HTTP ${status}`, + }; + } + + beforeEach(() => { + jest.clearAllMocks(); + process.env.TRIAL_RETRY_BASE_MS = '1'; + process.env.LICENSE_SERVICE_TOKEN = 'service-token'; + }); + + afterAll(() => { + delete process.env.TRIAL_RETRY_BASE_MS; + if (originalToken === undefined) delete process.env.LICENSE_SERVICE_TOKEN; + else process.env.LICENSE_SERVICE_TOKEN = originalToken; + }); + + it('posts the contract with the service token and a timeout, and returns the URL', async () => { + mockedAxios.post.mockResolvedValueOnce({ + data: { url: 'https://anythingmcp.com/api/stripe/checkout/start?intent=i1', expiresAt: 'x' }, + } as any); + + await expect(makeService().createCheckoutIntent(payload)).resolves.toEqual({ + url: 'https://anythingmcp.com/api/stripe/checkout/start?intent=i1', + }); + const [url, body, config] = mockedAxios.post.mock.calls[0] as any[]; + expect(url).toMatch(/\/api\/stripe\/checkout-intent$/); + expect(body).toEqual(payload); + expect(config.headers['x-amcp-service-token']).toBe('service-token'); + expect(config.timeout).toBeGreaterThan(0); + }); + + it('retries once on an upstream fault', async () => { + mockedAxios.post + .mockRejectedValueOnce(httpError(503)) + .mockResolvedValueOnce({ data: { url: 'https://anythingmcp.com/c' } } as any); + + await expect(makeService().createCheckoutIntent(payload)).resolves.toEqual({ + url: 'https://anythingmcp.com/c', + }); + expect(mockedAxios.post).toHaveBeenCalledTimes(2); + }); + + it('does not retry a validation error and reports it as unavailable', async () => { + mockedAxios.post.mockRejectedValue(httpError(400, { error: 'bad plan' })); + + const err = await makeService() + .createCheckoutIntent(payload) + .catch((e) => e); + expect(err).toBeInstanceOf(CheckoutUnavailableError); + expect(err.upstreamStatus).toBe(400); + expect(mockedAxios.post).toHaveBeenCalledTimes(1); + }); + + it('refuses an answer without a URL', async () => { + mockedAxios.post.mockResolvedValueOnce({ data: { ok: true } } as any); + await expect(makeService().createCheckoutIntent(payload)).rejects.toBeInstanceOf( + CheckoutUnavailableError, + ); + expect(mockedAxios.post).toHaveBeenCalledTimes(1); + }); + + it('does not call the licence site at all without a service token', async () => { + delete process.env.LICENSE_SERVICE_TOKEN; + await expect(makeService().createCheckoutIntent(payload)).rejects.toBeInstanceOf( + CheckoutUnavailableError, + ); + expect(mockedAxios.post).not.toHaveBeenCalled(); + }); +}); + +describe('LicenseController.checkoutLink', () => { + const originalFrontend = process.env.FRONTEND_URL; + const adminReq = { + user: { sub: 'u1', email: 'admin@example.com', role: 'ADMIN', organizationId: 'org-1' }, + }; + + function makeController(opts: { isCloud?: boolean; license?: any; click?: any; fail?: boolean } = {}) { + const licenseService = { + getCurrentLicense: jest.fn(async () => (opts.license === undefined ? trialLicense() : opts.license)), + createCheckoutIntent: jest.fn(async () => { + if (opts.fail) throw new CheckoutUnavailableError('Checkout intent failed: HTTP 500', 500); + return { url: 'https://anythingmcp.com/api/stripe/checkout/start?intent=i1' }; + }), + }; + const productEvents = { clickIdForUser: jest.fn(async () => opts.click ?? null) }; + const controller = new LicenseController( + licenseService as any, + {} as any, + {} as any, + {} as any, + { isCloud: () => opts.isCloud ?? true } as any, + productEvents as any, + ); + return { controller, licenseService, productEvents }; + } + + beforeEach(() => { + process.env.FRONTEND_URL = 'https://cloud.example.com'; + jest.useFakeTimers({ now: NOW, doNotFake: ['nextTick', 'setImmediate'] }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + afterAll(() => { + if (originalFrontend === undefined) delete process.env.FRONTEND_URL; + else process.env.FRONTEND_URL = originalFrontend; + }); + + it('builds the checkout from the session, with the free trial end as trialEnd', async () => { + const { controller, licenseService, productEvents } = makeController({ + click: { gclid: 'g-1', ad_consent: 'granted' }, + }); + + const res = await controller.checkoutLink(adminReq, { + plan: 'team', + billingPeriod: 'yearly', + trial: true, + }); + + expect(res).toEqual({ url: 'https://anythingmcp.com/api/stripe/checkout/start?intent=i1' }); + expect(licenseService.getCurrentLicense).toHaveBeenCalledWith('org-1'); + expect(productEvents.clickIdForUser).toHaveBeenCalledWith('u1'); + expect(licenseService.createCheckoutIntent).toHaveBeenCalledWith({ + email: 'admin@example.com', + plan: 'cloud_team', + billingPeriod: 'yearly', + trialEnd: IN_FIVE_DAYS.toISOString(), + returnUrl: 'https://cloud.example.com/settings/license/activate', + organizationId: 'org-1', + adMetadata: { ad_consent: 'granted', gclid: 'g-1' }, + }); + }); + + it('ignores an email or workspace smuggled into the body', async () => { + const { controller, licenseService } = makeController(); + + await controller.checkoutLink(adminReq, { + plan: 'starter', + billingPeriod: 'monthly', + trial: false, + email: 'victim@example.com', + organizationId: 'org-other', + } as any); + + const [payload] = licenseService.createCheckoutIntent.mock.calls[0] as any[]; + expect(payload.email).toBe('admin@example.com'); + expect(payload.organizationId).toBe('org-1'); + expect(licenseService.getCurrentLicense).toHaveBeenCalledWith('org-1'); + }); + + it('asks to pay now when no trial is requested', async () => { + const { controller, licenseService } = makeController(); + await controller.checkoutLink(adminReq, { plan: 'business', billingPeriod: 'monthly', trial: false }); + const [payload] = licenseService.createCheckoutIntent.mock.calls[0] as any[]; + expect(payload).not.toHaveProperty('trialEnd'); + expect(payload).not.toHaveProperty('adMetadata'); + }); + + it('asks to pay now when the trial is requested after the free trial ended', async () => { + const { controller, licenseService } = makeController({ + license: null, // getCurrentLicense returns active licences only; an ended trial is expired + }); + await controller.checkoutLink(adminReq, { plan: 'team', billingPeriod: 'monthly', trial: true }); + const [payload] = licenseService.createCheckoutIntent.mock.calls[0] as any[]; + expect(payload).not.toHaveProperty('trialEnd'); + + const late = makeController({ + license: trialLicense({ expiresAt: new Date(NOW.getTime() - 60_000) }), + }); + await late.controller.checkoutLink(adminReq, { plan: 'team', billingPeriod: 'monthly', trial: true }); + const [latePayload] = late.licenseService.createCheckoutIntent.mock.calls[0] as any[]; + expect(latePayload).not.toHaveProperty('trialEnd'); + }); + + it('does not exist on a self-hosted install', async () => { + const { controller, licenseService } = makeController({ isCloud: false }); + await expect( + controller.checkoutLink(adminReq, { plan: 'team', billingPeriod: 'monthly', trial: true }), + ).rejects.toBeInstanceOf(NotFoundException); + expect(licenseService.getCurrentLicense).not.toHaveBeenCalled(); + expect(licenseService.createCheckoutIntent).not.toHaveBeenCalled(); + }); + + it('refuses a non-admin, in the handler as well as in the guard', async () => { + const { controller, licenseService } = makeController(); + const memberReq = { user: { ...adminReq.user, role: 'EDITOR' } }; + await expect( + controller.checkoutLink(memberReq, { plan: 'team', billingPeriod: 'monthly', trial: true }), + ).rejects.toBeInstanceOf(ForbiddenException); + expect(licenseService.createCheckoutIntent).not.toHaveBeenCalled(); + + const guard = new RolesGuard(new Reflector()); + const context = { + getHandler: () => LicenseController.prototype.checkoutLink, + getClass: () => LicenseController, + switchToHttp: () => ({ getRequest: () => memberReq }), + } as unknown as ExecutionContext; + expect(() => guard.canActivate(context)).toThrow(ForbiddenException); + }); + + it('does not sell a second subscription to a paying workspace', async () => { + const { controller, licenseService } = makeController({ + license: trialLicense({ plan: 'cloud_team', expiresAt: null }), + }); + await expect( + controller.checkoutLink(adminReq, { plan: 'business', billingPeriod: 'monthly', trial: false }), + ).rejects.toBeInstanceOf(ConflictException); + expect(licenseService.createCheckoutIntent).not.toHaveBeenCalled(); + }); + + it('lets a customer whose paid licence ended subscribe again', async () => { + for (const status of ['revoked', 'expired']) { + const { controller, licenseService } = makeController({ + license: trialLicense({ plan: 'starter', status, expiresAt: null }), + }); + await expect( + controller.checkoutLink(adminReq, { plan: 'starter', billingPeriod: 'monthly', trial: true }), + ).resolves.toEqual({ url: expect.any(String) }); + // No live trial, so no trial end: a returning customer pays now. + expect(licenseService.createCheckoutIntent).toHaveBeenCalledWith( + expect.not.objectContaining({ trialEnd: expect.anything() }), + ); + } + }); + + it('maps any licence-site failure to a generic 502', async () => { + const { controller } = makeController({ fail: true }); + const err = await controller + .checkoutLink(adminReq, { plan: 'team', billingPeriod: 'monthly', trial: true }) + .catch((e) => e); + expect(err).toBeInstanceOf(BadGatewayException); + expect(err.message).not.toMatch(/HTTP 500/); + }); +}); diff --git a/packages/backend/src/license/license-checkout.ts b/packages/backend/src/license/license-checkout.ts new file mode 100644 index 00000000..1d530148 --- /dev/null +++ b/packages/backend/src/license/license-checkout.ts @@ -0,0 +1,89 @@ +import type { LicenseInfo } from './license.service'; +import type { AttributionClickId } from '../audit/signup-attribution'; + +/** + * Checkout links for AnythingMCP Cloud (DEPLOYMENT_MODE=cloud only). + * + * The licence site (anythingmcp.com) owns Stripe. This server asks it for a + * checkout intent on behalf of the signed-in admin, server to server, and + * hands the browser the one-time URL it answers with. The browser goes through + * Stripe Checkout and comes back to /settings/license/activate#key=…, which + * the existing activation page handles. + */ + +export const CHECKOUT_PLANS = ['starter', 'team', 'business'] as const; + +/** A card trial needs at least this much of the free trial left (Stripe: 48 h). */ +export const CARD_TRIAL_MIN_LEAD_MS = 48 * 60 * 60 * 1000; +export type CheckoutPlan = (typeof CHECKOUT_PLANS)[number]; + +export const CHECKOUT_BILLING_PERIODS = ['monthly', 'yearly'] as const; +export type CheckoutBillingPeriod = (typeof CHECKOUT_BILLING_PERIODS)[number]; + +/** Body of POST {licence site}/api/stripe/checkout-intent (the agreed contract). */ +export interface CheckoutIntentPayload { + email: string; + plan: `cloud_${CheckoutPlan}`; + billingPeriod: CheckoutBillingPeriod; + /** Present: a card trial ending then (the site clamps it). Absent: pay now. */ + trialEnd?: string; + returnUrl: string; + organizationId?: string; + adMetadata?: { + ad_consent: 'granted'; + gclid?: string; + gbraid?: string; + wbraid?: string; + }; +} + +/** + * When a card trial should end: exactly when the workspace's free trial would + * have, so adding a card never shortens or lengthens the seven days the user + * was promised. Only a live 'trial' licence qualifies; anything else (trial + * over, a paid plan, no licence) answers null, which means "pay now". + */ +export function cardTrialEnd(license: LicenseInfo | null, now: Date = new Date()): Date | null { + if (!license || license.plan !== 'trial' || license.status !== 'active') return null; + if (!license.expiresAt) return null; + const end = new Date(license.expiresAt); + // Stripe needs a trial end at least 48 hours out; the licence site sells + // without a trial when it is nearer, rather than lengthening the trial. + if (Number.isNaN(end.getTime()) || end.getTime() < now.getTime() + CARD_TRIAL_MIN_LEAD_MS) return null; + return end; +} + +/** + * The ad metadata the licence site may attach to the checkout, so a purchase + * can be reported to Google Ads. Only a click id stored with ad consent + * granted (clickIdForUser already guarantees that; checked again here), and + * only the id fields, never the capture timestamp. + */ +export function checkoutAdMetadata( + click: AttributionClickId | null | undefined, +): CheckoutIntentPayload['adMetadata'] | undefined { + if (!click || click.ad_consent !== 'granted') return undefined; + const out: NonNullable = { ad_consent: 'granted' }; + for (const key of ['gclid', 'gbraid', 'wbraid'] as const) { + const value = click[key]; + if (typeof value === 'string' && value) out[key] = value; + } + return out.gclid || out.gbraid || out.wbraid ? out : undefined; +} + +/** The cloud frontend origin the licence site sends the buyer back to. */ +export function cloudFrontendOrigin(env: NodeJS.ProcessEnv = process.env): string { + const configured = env.FRONTEND_URL || env.CLOUD_PUBLIC_URL || 'https://cloud.anythingmcp.com'; + return configured.replace(/\/+$/, ''); +} + +/** Raised when the licence site cannot give us a checkout URL. */ +export class CheckoutUnavailableError extends Error { + constructor( + message: string, + readonly upstreamStatus?: number, + ) { + super(message); + this.name = 'CheckoutUnavailableError'; + } +} diff --git a/packages/backend/src/license/license.controller.spec.ts b/packages/backend/src/license/license.controller.spec.ts index 838d967a..d215df0c 100644 --- a/packages/backend/src/license/license.controller.spec.ts +++ b/packages/backend/src/license/license.controller.spec.ts @@ -28,6 +28,7 @@ describe('LicenseController — activate-trial is idempotent', () => { {} as any, { findById: jest.fn(async () => user) } as any, { isCloud: () => true } as any, + {} as any, ); return { controller, licenseService }; } @@ -75,3 +76,54 @@ describe('LicenseController — activate-trial is idempotent', () => { expect(result.trialDaysLeft).toBe(0); }); }); + +/** + * A Cloud workspace whose trial ran out used to get `plan: null` from + * /status, the same answer as a workspace that never had a licence. The app + * then offered "Start 7-Day Free Trial" (which the licence site will not grant + * twice) instead of "your trial has ended, choose a plan". + */ +describe('LicenseController — status reports an ended licence', () => { + const req = { headers: { authorization: 'Bearer t' } }; + + function makeController(opts: { active?: any; inactive?: any; cloud?: boolean }) { + const licenseService = { + getCurrentLicense: jest.fn(async () => opts.active ?? null), + getLatestInactiveLicense: jest.fn(async () => opts.inactive ?? null), + }; + const controller = new LicenseController( + licenseService as any, + {} as any, + { verifyToken: () => ({ organizationId: 'org-1' }) } as any, + {} as any, + { isCloud: () => opts.cloud ?? true } as any, + {} as any, + ); + return { controller, licenseService }; + } + + it('reports an expired trial as a trial with no days left, without features', async () => { + const expiresAt = new Date(Date.now() - 3600_000); + const { controller } = makeController({ inactive: { plan: 'trial', status: 'expired', expiresAt } }); + const status: any = await controller.getStatus(req); + expect(status).toMatchObject({ plan: 'trial', status: 'expired', trialDaysLeft: 0, features: null }); + }); + + it('reports a lapsed paid licence with its status and no trial countdown', async () => { + const { controller } = makeController({ inactive: { plan: 'starter', status: 'expired', expiresAt: null } }); + const status: any = await controller.getStatus(req); + expect(status).toMatchObject({ plan: 'starter', status: 'expired', features: null }); + expect(status.trialDaysLeft).toBeUndefined(); + }); + + it('still answers "none" for a workspace that never had a licence', async () => { + const { controller } = makeController({}); + expect(await controller.getStatus(req)).toMatchObject({ plan: null, status: 'none' }); + }); + + it('does not look up inactive licences on self-hosted', async () => { + const { controller, licenseService } = makeController({ cloud: false }); + await controller.getStatus(req); + expect(licenseService.getLatestInactiveLicense).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/backend/src/license/license.controller.ts b/packages/backend/src/license/license.controller.ts index 60794e2f..27f5bd03 100644 --- a/packages/backend/src/license/license.controller.ts +++ b/packages/backend/src/license/license.controller.ts @@ -7,18 +7,33 @@ import { Req, UseGuards, BadRequestException, + BadGatewayException, + ConflictException, ForbiddenException, + NotFoundException, + HttpCode, Logger, } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { AuthGuard } from '@nestjs/passport'; -import { IsString, IsOptional, Matches } from 'class-validator'; +import { Throttle } from '@nestjs/throttler'; +import { IsBoolean, IsIn, IsString, IsOptional, 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'; +import { ProductEventService } from '../audit/product-event.service'; +import { + CHECKOUT_BILLING_PERIODS, + CHECKOUT_PLANS, + CheckoutBillingPeriod, + CheckoutPlan, + cardTrialEnd, + checkoutAdMetadata, + cloudFrontendOrigin, +} from './license-checkout'; class SetLicenseKeyDto { @IsString() @@ -28,6 +43,17 @@ class SetLicenseKeyDto { licenseKey: string; } +class CheckoutLinkDto { + @IsIn(CHECKOUT_PLANS as unknown as string[]) + plan: CheckoutPlan; + + @IsIn(CHECKOUT_BILLING_PERIODS as unknown as string[]) + billingPeriod: CheckoutBillingPeriod; + + @IsBoolean() + trial: boolean; +} + class BillingPortalDto { @IsOptional() @IsString() @@ -45,6 +71,7 @@ export class LicenseController { private readonly authService: AuthService, private readonly usersService: UsersService, private readonly deployment: DeploymentService, + private readonly productEvents: ProductEventService, ) {} @Get('status') @@ -67,6 +94,26 @@ export class LicenseController { const license = await this.licenseService.getCurrentLicense(organizationId); if (!license) { + // Cloud: a workspace whose trial ran out (or whose paid licence lapsed) + // has no active licence, but it is not a workspace that never had one. + // Report the latest licence's plan and status so the app can say "your + // trial has ended" and offer the plans, instead of "start a free trial" + // (a trial the licence site will not grant a second time). Reporting + // only: no features, and gating still uses the active licence alone. + const last = + this.deployment.isCloud() && organizationId + ? await this.licenseService.getLatestInactiveLicense(organizationId) + : null; + if (last) { + return { + plan: last.plan, + status: last.status, + features: null, + expiresAt: last.expiresAt, + instanceId: null, + ...(last.plan === 'trial' && { trialDaysLeft: 0 }), + }; + } return { plan: null, status: 'none', features: null, expiresAt: null, instanceId: null }; } @@ -157,6 +204,74 @@ export class LicenseController { } } + @Post('checkout-link') + @HttpCode(200) + @UseGuards(AuthGuard('jwt'), RolesGuard) + @Roles('ADMIN') + // Each call mints a checkout intent on the licence site: a person clicks + // this a few times at most, a loop should not be able to do more. + @Throttle({ default: { limit: 10, ttl: 60_000 } }) + @ApiBearerAuth() + @ApiOperation({ + summary: + 'Stripe Checkout link for a cloud plan, optionally as a card trial ending with the free trial (cloud, ADMIN)', + }) + async checkoutLink(@Req() req: any, @Body() dto: CheckoutLinkDto): Promise<{ url: string }> { + // Cloud only. Self-hosted buys its licence on the pricing page and has no + // workspace billing here; answer as if the route did not exist. + if (!this.deployment.isCloud()) { + throw new NotFoundException(); + } + // RolesGuard already refuses other roles; checked again because a + // checkout binds the workspace's billing to this person's email. + if (req.user?.role !== 'ADMIN') { + throw new ForbiddenException('Only workspace administrators can start a subscription'); + } + + // Who pays and for which workspace comes from the session, never from the + // body (the DTO has no such fields and the global pipe rejects extras). + const email: string | undefined = req.user?.email; + const organizationId: string | undefined = req.user?.organizationId; + if (!email || !organizationId) { + throw new BadRequestException('No workspace to subscribe'); + } + + const current = await this.licenseService.getCurrentLicense(organizationId); + // A paying workspace changes plan in the billing portal. A second checkout + // would add a second subscription beside the first and bill both. A paid + // licence that was revoked or expired (a customer coming back) does not + // block a new purchase. + if (current && current.plan !== 'trial' && current.status === 'active') { + throw new ConflictException( + 'This workspace already has a subscription. Change your plan in the billing portal.', + ); + } + + // A card trial ends when the free trial would have. Asked for after the + // trial is over, it is a plain purchase: no trialEnd means pay now. + const trialEnd = dto.trial ? cardTrialEnd(current) : null; + const adMetadata = checkoutAdMetadata( + await this.productEvents.clickIdForUser(req.user.sub).catch(() => null), + ); + + try { + return await this.licenseService.createCheckoutIntent({ + email, + plan: `cloud_${dto.plan}`, + billingPeriod: dto.billingPeriod, + ...(trialEnd && { trialEnd: trialEnd.toISOString() }), + returnUrl: `${cloudFrontendOrigin()}/settings/license/activate`, + organizationId, + ...(adMetadata && { adMetadata }), + }); + } catch (err: any) { + this.logger.warn(`Checkout link for org ${organizationId} failed: ${err?.message ?? err}`); + throw new BadGatewayException( + 'The checkout could not be opened right now. Please try again in a moment.', + ); + } + } + @Post('verify') @UseGuards(AuthGuard('jwt'), RolesGuard) @Roles('ADMIN') diff --git a/packages/backend/src/license/license.service.ts b/packages/backend/src/license/license.service.ts index 43c2afb3..473ad54c 100644 --- a/packages/backend/src/license/license.service.ts +++ b/packages/backend/src/license/license.service.ts @@ -4,11 +4,17 @@ import * as crypto from 'crypto'; import { PrismaService } from '../common/prisma.service'; import { DeploymentService } from '../common/deployment.service'; import { SiteSettingsService } from '../settings/site-settings.service'; +import { CheckoutIntentPayload, CheckoutUnavailableError } from './license-checkout'; +// Production always talks to anythingmcp.com. The licence site decides +// which plan an installation runs; a URL taken from the environment would let +// 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). const LICENSE_API_URL = process.env.NODE_ENV === 'production' ? 'https://anythingmcp.com' - : 'http://localhost:3100'; + : process.env.LICENSE_API_URL?.replace(/\/+$/, '') || 'http://localhost:3100'; /** * How hard we chase a trial licence before giving up. The licence API is a @@ -19,6 +25,8 @@ const LICENSE_API_URL = const TRIAL_RETRY_ATTEMPTS = 3; /** Read at call time so a test (or an operator) can shrink the wait. */ const trialRetryBaseMs = () => Number(process.env.TRIAL_RETRY_BASE_MS ?? 600); +/** A checkout link is asked for by someone waiting on a spinner: one retry, no more. */ +const CHECKOUT_RETRY_ATTEMPTS = 2; export interface LicenseInfo { licenseKey: string; @@ -124,6 +132,63 @@ export class LicenseService implements OnModuleInit, OnModuleDestroy { } } + // ── Stripe Checkout (Cloud) ──────────────────────────────────────────────── + + /** + * Ask the licence site for a one-time Stripe Checkout URL (Cloud only; the + * caller checks the deployment mode). The licence site owns Stripe; it + * accepts this call only with our service token, so the email and workspace + * in the payload are the ones this server vouches for. + * + * A user is waiting on the other end, so the retry is short: one more try + * on throttling, an upstream fault or no answer at all. A duplicate intent + * is harmless (it simply expires unused). Every failure comes out as a + * CheckoutUnavailableError; the upstream detail stays in our log. + */ + async createCheckoutIntent(payload: CheckoutIntentPayload): Promise<{ url: string }> { + const headers = this.serviceHeaders(); + if (!headers['x-amcp-service-token']) { + this.logger.error('Checkout link requested but LICENSE_SERVICE_TOKEN is not set.'); + throw new CheckoutUnavailableError('Licence service token is not configured'); + } + + let lastErr: any; + for (let attempt = 1; attempt <= CHECKOUT_RETRY_ATTEMPTS; attempt++) { + try { + const { data } = await axios.post(`${this.apiBase}/api/stripe/checkout-intent`, payload, { + timeout: 10000, + headers, + }); + const url = typeof data?.url === 'string' ? data.url : ''; + if (!/^https?:\/\//i.test(url)) { + throw new CheckoutUnavailableError('Checkout intent answered without a URL'); + } + return { url }; + } catch (err: any) { + lastErr = err; + if ( + err instanceof CheckoutUnavailableError || + attempt === CHECKOUT_RETRY_ATTEMPTS || + !this.isRetriableLicenseError(err) + ) { + break; + } + await new Promise((resolve) => setTimeout(resolve, trialRetryBaseMs())); + } + } + + if (lastErr instanceof CheckoutUnavailableError) { + this.logger.warn(`Checkout intent failed: ${lastErr.message}`); + throw lastErr; + } + const status: number | undefined = lastErr?.response?.status; + const detail = lastErr?.response?.data?.error || lastErr?.message || 'no response'; + this.logger.warn( + `Checkout intent failed (${status ?? lastErr?.code ?? 'no response'}) for org ${payload.organizationId}: ${detail}`, + ); + throw new CheckoutUnavailableError(`Checkout intent failed: ${detail}`, status); + } + // ── Community License Request (sends key via email) ─────────────────────── async requestCommunityLicense( @@ -632,6 +697,22 @@ export class LicenseService implements OnModuleInit, OnModuleDestroy { // ── Get Current License ──────────────────────────────────────────────────── + /** + * The workspace's most recent licence that is no longer active (expired, + * revoked, invalid), for status reporting only — never for gating. Null + * when the workspace has none. + */ + async getLatestInactiveLicense( + organizationId: string, + ): Promise<{ plan: string; status: string; expiresAt: Date | null } | null> { + const license = await this.prisma.license.findFirst({ + where: { organizationId, status: { not: 'active' } }, + orderBy: { createdAt: 'desc' }, + select: { plan: true, status: true, expiresAt: true }, + }); + return license ?? null; + } + async getCurrentLicense(organizationId?: string): Promise { // 1. Per-org: find license directly assigned to this organization if (organizationId) { diff --git a/packages/backend/src/mcp-servers/mcp-servers.activity.spec.ts b/packages/backend/src/mcp-servers/mcp-servers.activity.spec.ts new file mode 100644 index 00000000..6cedeef2 --- /dev/null +++ b/packages/backend/src/mcp-servers/mcp-servers.activity.spec.ts @@ -0,0 +1,42 @@ +import { NotFoundException } from '@nestjs/common'; +import { McpServersController } from './mcp-servers.controller'; + +/** + * The connect page polls this to see the first request from an AI client + * arrive. It must answer for the caller's own servers only. + */ +describe('McpServersController.activity', () => { + const last = new Date('2026-09-30T10:00:00Z'); + + function makeController(server: any) { + const service = { + findById: jest.fn(async () => server), + usageByServer: jest.fn(async (ids: string[]) => + new Map(ids.map((id) => [id, { calls30d: 3, lastCallAt: last }])), + ), + }; + const controller = new McpServersController(service as any, {} as any, {} as any); + return { controller, service }; + } + + it("returns the server's calls and last call time", async () => { + const { controller } = makeController({ id: 's1', organizationId: 'org-1' }); + const res = await controller.activity({ user: { organizationId: 'org-1' } }, 's1'); + expect(res).toEqual({ calls30d: 3, lastCallAt: last }); + }); + + it("answers 404 for another workspace's server without reading its usage", async () => { + const { controller, service } = makeController({ id: 's2', organizationId: 'org-2' }); + await expect( + controller.activity({ user: { organizationId: 'org-1' } }, 's2'), + ).rejects.toBeInstanceOf(NotFoundException); + expect(service.usageByServer).not.toHaveBeenCalled(); + }); + + it('answers 404 for a server that does not exist', async () => { + const { controller } = makeController(null); + await expect( + controller.activity({ user: { organizationId: 'org-1' } }, 'nope'), + ).rejects.toBeInstanceOf(NotFoundException); + }); +}); diff --git a/packages/backend/src/mcp-servers/mcp-servers.controller.ts b/packages/backend/src/mcp-servers/mcp-servers.controller.ts index 6db0c4be..5a6712f2 100644 --- a/packages/backend/src/mcp-servers/mcp-servers.controller.ts +++ b/packages/backend/src/mcp-servers/mcp-servers.controller.ts @@ -149,6 +149,19 @@ export class McpServersController { return server; } + @Get(':id/activity') + @ApiOperation({ + summary: + 'Calls in the last 30 days and the time of the last one. Polled by the connect page to show when the first request from an AI client arrives.', + }) + async activity(@Req() req: any, @Param('id') id: string) { + const server = await this.mcpServersService.findById(id); + if (!server) throw new NotFoundException('MCP server not found'); + this.assertOrgMatch(server, req); + const usage = await this.mcpServersService.usageByServer([id]); + return usage.get(id) ?? { calls30d: 0, lastCallAt: null }; + } + @Put(':id') @ApiOperation({ summary: 'Update MCP server' }) async update(@Req() req: any, @Param('id') id: string, @Body() dto: UpdateMcpServerDto) { diff --git a/packages/backend/src/settings/email.service.ts b/packages/backend/src/settings/email.service.ts index 7b09c3cc..c1f113bd 100644 --- a/packages/backend/src/settings/email.service.ts +++ b/packages/backend/src/settings/email.service.ts @@ -6,10 +6,15 @@ import { OrgSettingsService } from './org-settings.service'; import { PrismaService } from '../common/prisma.service'; import { DeploymentService } from '../common/deployment.service'; +// Production always talks to anythingmcp.com. The licence site decides +// which plan an installation runs; a URL taken from the environment would let +// 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). const LICENSE_API_URL = process.env.NODE_ENV === 'production' ? 'https://anythingmcp.com' - : 'http://localhost:3100'; + : process.env.LICENSE_API_URL?.replace(/\/+$/, '') || 'http://localhost:3100'; @Injectable() export class EmailService { diff --git a/packages/backend/src/users/users.controller.ts b/packages/backend/src/users/users.controller.ts index 7addbb79..8d1bccb6 100644 --- a/packages/backend/src/users/users.controller.ts +++ b/packages/backend/src/users/users.controller.ts @@ -155,7 +155,24 @@ export class UsersController { async updateProfile(@Req() req: any, @Body() dto: UpdateProfileDto) { const data: any = {}; if (dto.name) data.name = dto.name; - if (dto.email) data.email = dto.email; + if (dto.email) { + // Cloud: the address is the verified identity (sign-in, password reset, + // billing), and this endpoint neither re-verifies nor asks for the + // password. A stolen session could otherwise move the account to + // another address and take it over through a reset. The app never + // changes it from here; support does, on request. Self-hosted operators + // keep the old behaviour. + if (process.env.DEPLOYMENT_MODE === 'cloud') { + const current = await this.usersService.findById(req.user.sub); + if (dto.email.trim().toLowerCase() !== current?.email?.trim().toLowerCase()) { + throw new BadRequestException( + 'Your email address cannot be changed here. Contact support@anythingmcp.com to change it.', + ); + } + } else { + data.email = dto.email; + } + } const user = await this.usersService.update(req.user.sub, data); const { passwordHash, ...profile } = user; diff --git a/packages/frontend/src/app/login/page.tsx b/packages/frontend/src/app/login/page.tsx index 3b3c5722..baa371c3 100644 --- a/packages/frontend/src/app/login/page.tsx +++ b/packages/frontend/src/app/login/page.tsx @@ -5,6 +5,7 @@ import Link from 'next/link'; import { useRouter, useSearchParams } from 'next/navigation'; import { ApiError, auth, license, server, sso, type SsoProviderButton, recoveryCodes as recoveryApi } from '@/lib/api'; import { useAuth } from '@/lib/auth-context'; +import { useToast } from '@/components/toast'; import { buildPricingUrl } from '@/lib/marketing'; import { LogoIcon } from '@/components/logo-icon'; import { ProviderMark } from '@/components/provider-mark'; @@ -14,6 +15,13 @@ import { cn } from '@/lib/utils'; import { safeRedirect } from '@/lib/safe-redirect'; import { captureSignupAttribution, clearSignupAttribution, getSignupAttribution } from '@/lib/attribution'; import { pushSignUpVerified } from '@/lib/conversion'; +import { + cardTrialEligible, + parsePlanIntent, + readCardTrialPrompt, + savePlanIntent, + writeCardTrialPrompt, +} from '@/lib/card-trial'; type SetupStep = 'auth' | 'verify-email' | 'check-inbox' | 'license-choice' | 'license-email-sent' | 'license-key' | 'trial-activated'; @@ -67,12 +75,15 @@ function LoginForm() { const router = useRouter(); const searchParams = useSearchParams(); const { login } = useAuth(); + const toast = useToast(); const redirectTo = safeRedirect(searchParams.get('redirect')); const emailVerifiedParam = searchParams.get('emailVerified'); const modeParam = searchParams.get('mode'); // 'register' or 'login' const ssoCode = searchParams.get('sso'); const errorParam = searchParams.get('error'); + const planParam = searchParams.get('plan'); + const periodParam = searchParams.get('period'); const [ssoProviders, setSsoProviders] = useState([]); const [ssoExchanging, setSsoExchanging] = useState(Boolean(ssoCode)); @@ -98,6 +109,43 @@ function LoginForm() { if (isCloudMode) captureSignupAttribution(); }, [isCloudMode]); + // Cloud only: the pricing page opens sign-up with the plan the visitor + // picked (`?plan=cloud_team&period=yearly`). Kept in localStorage so it + // survives email verification, which may happen days later in another tab, + // and preselects that plan on the card-trial offer. + useEffect(() => { + if (!isCloudMode) return; + const intent = parsePlanIntent(planParam, periodParam); + if (intent) savePlanIntent(intent); + }, [isCloudMode, planParam, periodParam]); + + /** + * Cloud only: right after the trial starts, an admin headed for the + * dashboard is offered the card trial (/start-trial) instead of the + * "Trial activated" card, once. A sign-in on its way somewhere specific + * (an OAuth consent, an install link) is left alone. + */ + const offerCardTrial = ( + u: { id?: string; role?: string } | null | undefined, + trial: { plan: string; expiresAt: string | null; trialDaysLeft: number }, + ): boolean => { + if (!isCloudMode || redirectTo !== '/' || !u?.id) return false; + const eligible = cardTrialEligible({ + isCloud: true, + role: u.role, + license: { + plan: trial.plan, + status: 'active', + expiresAt: trial.expiresAt, + trialDaysLeft: trial.trialDaysLeft, + }, + }); + if (!eligible || readCardTrialPrompt(u.id) !== null) return false; + writeCardTrialPrompt(u.id, 'shown'); + router.push('/start-trial'); + return true; + }; + // Surface a failure the SSO callback redirected back with. useEffect(() => { if (errorParam) setError(errorParam); @@ -188,6 +236,18 @@ function LoginForm() { const loginResult = await auth.login(email, password); result = loginResult; needsLicenseSetup = !!loginResult.needsLicenseSetup; + // A verified account can only be one that existed before this + // sign-up: say so, rather than silently landing in a workspace + // the person thought they were creating. Only someone who typed + // the account's password sees this, so it reveals nothing. + if (loginResult.user?.emailVerified) { + toast.show({ + title: 'You already have an account', + description: 'This address was already registered, so we signed you in to your existing workspace.', + tone: 'info', + durationMs: 9000, + }); + } } catch { setUserEmail(email); setSetupStep('check-inbox'); @@ -217,6 +277,14 @@ function LoginForm() { setAuthToken(result.accessToken); try { const trialResult = await license.activateTrial(result.accessToken); + if (offerCardTrial(result.user, trialResult)) return; + // "Trial Activated!" only for a trial started just now. A workspace + // that already holds a licence (a running trial, a paid plan) goes + // straight in, instead of being told it has "0 days" left. + if (!trialResult.trialStarted) { + router.push(redirectTo); + return; + } setTrialDaysLeft(trialResult.trialDaysLeft); setSetupStep('trial-activated'); } catch { @@ -252,6 +320,7 @@ function LoginForm() { // Cloud mode: auto-activate trial try { const trialResult = await license.activateTrial(authToken); + if (offerCardTrial(storedUser, trialResult)) return; setTrialDaysLeft(trialResult.trialDaysLeft); setSetupStep('trial-activated'); } catch (trialErr: any) { diff --git a/packages/frontend/src/app/mcp-server/[id]/page.tsx b/packages/frontend/src/app/mcp-server/[id]/page.tsx index f8a01b38..afb5a874 100644 --- a/packages/frontend/src/app/mcp-server/[id]/page.tsx +++ b/packages/frontend/src/app/mcp-server/[id]/page.tsx @@ -9,6 +9,7 @@ import { Button, buttonVariants } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { Badge, StatusPill, type Tone } from '@/components/ui/badge'; import { cn } from '@/lib/utils'; +import { ConnectionCheck } from '@/components/connection-check'; // Opens claude.ai straight on its "Add custom connector" dialog. Connectors // moved from Settings to Customize → Connectors; the old settings URL now only @@ -640,6 +641,7 @@ export default function McpServerDetailPage() {
{/* Connect a client — sticky right column on desktop, shown first on mobile */}
)} + {/* Connectors built, but no AI client has called them yet: the step + most workspaces never take. Send them to the connect page, which + shows live when the first request arrives. */} + {!dataLoading && stats.connectors > 0 && analytics && !analytics.totalInvocations && !stats.invocations24h && ( +
+
+
Next step: connect your AI client
+
+ Your connectors are ready. Add your MCP server to Claude, ChatGPT or Cursor and make the first call. +
+
+ + + +
+ )} + {/* Stat cards */}
router.replace('/settings/license'), 1500); + // A full load rather than a client-side route change: the app shell + // (trial banner, licence wall) read the licence before activation and + // would keep showing the trial until the next reload. + setTimeout(() => window.location.replace('/settings/license'), 1500); }) .catch((err: any) => { setPhase('error'); diff --git a/packages/frontend/src/app/settings/license/page.tsx b/packages/frontend/src/app/settings/license/page.tsx index aafb4d46..cab128b2 100644 --- a/packages/frontend/src/app/settings/license/page.tsx +++ b/packages/frontend/src/app/settings/license/page.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState, useEffect } from 'react'; +import Link from 'next/link'; import { useAuth } from '@/lib/auth-context'; import { license } from '@/lib/api'; import { usePricingUrl } from '@/lib/use-pricing-url'; @@ -10,6 +11,7 @@ import { Card } from '@/components/ui/card'; import { cn } from '@/lib/utils'; import { useEdition, notifyEditionChanged } from '@/lib/use-edition'; import { EditionCard } from '@/components/edition-card'; +import { cardTrialDisplayEnd, cardTrialEligible, formatTrialEnd } from '@/lib/card-trial'; interface LicenseStatus { plan: string | null; @@ -42,6 +44,11 @@ export default function LicenseSettingsPage() { !!status?.plan && status.plan !== 'trial' && status.plan !== 'community'; + // Cloud admin on a running free trial: offer the card trial (/start-trial). + const cardTrialOffer = cardTrialEligible({ isCloud, role: user?.role, license: status }); + const cardTrialDate = cardTrialOffer + ? formatTrialEnd(cardTrialDisplayEnd(status?.expiresAt), 'long') + : null; const loadStatus = async () => { try { @@ -348,17 +355,41 @@ export default function LicenseSettingsPage() { {isCloud && status?.plan === 'trial' && (

Upgrade Plan

-

- Upgrade to a paid plan to continue using AnythingMCP Cloud after your trial ends. -

- - View Plans - + {cardTrialOffer ? ( + <> +

+ Add a payment method to keep AnythingMCP Cloud running after your trial. Nothing is + charged before {cardTrialDate ?? 'your trial ends'}, and you can cancel anytime. +

+
+ + Add payment method + + + Compare plans + +
+ + ) : ( + <> +

+ Upgrade to a paid plan to continue using AnythingMCP Cloud after your trial ends. +

+ + View Plans + + + )}
)}
diff --git a/packages/frontend/src/app/start-trial/page.tsx b/packages/frontend/src/app/start-trial/page.tsx new file mode 100644 index 00000000..a507bd7e --- /dev/null +++ b/packages/frontend/src/app/start-trial/page.tsx @@ -0,0 +1,169 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useAuth } from '@/lib/auth-context'; +import { license } from '@/lib/api'; +import { LogoIcon } from '@/components/logo-icon'; +import { PlanPicker } from '@/components/plan-picker'; +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { + DEFAULT_SELECTION, + cardTrialDisplayEnd, + cardTrialEligible, + formatTrialEnd, + planById, + readPlanIntent, + writeCardTrialPrompt, + type PlanSelection, +} from '@/lib/card-trial'; +import { useCardCheckout } from '@/lib/use-card-checkout'; + +/** + * Cloud only: the card-trial offer shown once after sign-up (see + * OnboardingRedirect). "Start free trial" opens Stripe Checkout with a trial + * that ends when the free trial would have; nothing is charged today. The + * no-card trial the user already has stays one small link away, in the + * corner, on purpose. + * + * Anyone it does not apply to (self-hosted, non-admins, no live trial) is + * sent to the dashboard. + */ +export default function StartTrialPage() { + const { token, user, isLoading, deploymentMode, deploymentModeLoaded } = useAuth(); + const router = useRouter(); + const checkout = useCardCheckout(); + const [trialEnd, setTrialEnd] = useState(null); + const [ready, setReady] = useState(false); + const [selection, setSelection] = useState(DEFAULT_SELECTION); + + useEffect(() => { + if (isLoading || !deploymentModeLoaded) return; + if (!token || !user) { + router.replace('/login?redirect=/start-trial'); + return; + } + if (deploymentMode !== 'cloud' || user.role !== 'ADMIN') { + router.replace('/'); + return; + } + let live = true; + license + .getStatus(token) + .then((lic) => { + if (!live) return; + if (!cardTrialEligible({ isCloud: true, role: user.role, license: lic })) { + router.replace('/'); + return; + } + setTrialEnd(cardTrialDisplayEnd(lic.expiresAt)); + setSelection(readPlanIntent() ?? DEFAULT_SELECTION); + setReady(true); + }) + .catch(() => { + if (live) router.replace('/'); + }); + return () => { + live = false; + }; + }, [isLoading, deploymentModeLoaded, deploymentMode, token, user, router]); + + const handleStart = async () => { + if (!user) return; + const ok = await checkout.start(selection, true); + if (ok) writeCardTrialPrompt(user.id, 'checkout'); + }; + + const handleSkip = () => { + if (user) writeCardTrialPrompt(user.id, 'skipped'); + // The dashboard decides what comes next (usually the /welcome wizard). + router.replace('/'); + }; + + if (!ready) { + return ( +
+
Loading…
+
+ ); + } + + const endLong = formatTrialEnd(trialEnd, 'long'); + const plan = planById(selection.plan); + + return ( +
+
+
+ + + + + AnythingMCP + +
+ {/* Deliberately quiet: a plain text link in the corner, not a button. */} + +
+ +
+
+

+ 7-day free trial +

+

+ Your 7-day free trial +

+

+ Every feature, free for 7 days. Add a card now and your plan simply carries on after the + trial, with nothing charged before{' '} + {endLong ? {endLong} : 'the trial ends'}. Or + try it without payment details and decide later. +

+
+ + + + +
+ + {checkout.error && ( +

+ {checkout.error} +

+ )} +

+ €0 today. {plan.name}, billed {selection.period === 'yearly' ? 'yearly' : 'monthly'} from{' '} + {endLong ?? 'the end of your trial'}. Secure checkout by Stripe. Cancel before then and + you pay nothing. +

+

+ Not ready to add a card?{' '} + +

+
+
+
+
+ ); +} diff --git a/packages/frontend/src/components/connection-check.tsx b/packages/frontend/src/components/connection-check.tsx new file mode 100644 index 00000000..56c8efea --- /dev/null +++ b/packages/frontend/src/components/connection-check.tsx @@ -0,0 +1,133 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { mcpServers, productEvents } from '@/lib/api'; +import { Card } from '@/components/ui/card'; + +/** How often the page asks whether a request has arrived. */ +const POLL_MS = 5_000; +/** After this long without a request the page stops asking (a reload restarts it). */ +const POLL_FOR_MS = 30 * 60_000; + +export function timeAgo(iso: string, now: number = Date.now()): string { + const minutes = Math.max(0, Math.round((now - new Date(iso).getTime()) / 60_000)); + if (minutes < 1) return 'just now'; + if (minutes < 60) return `${minutes} min ago`; + const hours = Math.round(minutes / 60); + if (hours < 24) return `${hours} h ago`; + const days = Math.round(hours / 24); + return `${days} day${days === 1 ? '' : 's'} ago`; +} + +type State = + | { kind: 'loading' } + | { kind: 'waiting' } + | { kind: 'arrived'; at: string } + | { kind: 'active'; at: string; calls: number }; + +/** + * Shows, live, whether an AI client has reached this server yet. + * + * Most workspaces that attach a connector never send a single MCP request, + * and nothing on the connect page told them whether the step they just did + * in Claude or ChatGPT worked. While no request has arrived this polls the + * server's activity and turns green the moment the first one lands. + */ +export function ConnectionCheck({ serverId, token }: { serverId: string; token: string }) { + const [state, setState] = useState({ kind: 'loading' }); + const sawWaiting = useRef(false); + + useEffect(() => { + let live = true; + let timer: ReturnType | undefined; + const startedAt = Date.now(); + + const check = async () => { + if (!live) return; + // A hidden tab does not need an answer; ask again when it is visible. + if (typeof document !== 'undefined' && document.visibilityState === 'hidden') { + timer = setTimeout(check, POLL_MS); + return; + } + try { + const activity = await mcpServers.activity(serverId, token); + if (!live) return; + if (activity.lastCallAt) { + if (sawWaiting.current) { + setState({ kind: 'arrived', at: activity.lastCallAt }); + productEvents.track('first_call_seen', token, { serverId }); + } else { + setState({ kind: 'active', at: activity.lastCallAt, calls: activity.calls30d }); + } + return; + } + sawWaiting.current = true; + setState({ kind: 'waiting' }); + } catch { + // A failed poll is not news; keep the current state and try again. + } + if (Date.now() - startedAt < POLL_FOR_MS) timer = setTimeout(check, POLL_MS); + }; + + void check(); + return () => { + live = false; + if (timer) clearTimeout(timer); + }; + }, [serverId, token]); + + if (state.kind === 'loading') return null; + + if (state.kind === 'active') { + return ( +
+ + Connected · last request {timeAgo(state.at)} · {state.calls.toLocaleString('en-US')} in 30 days +
+ ); + } + + if (state.kind === 'arrived') { + return ( + +
+ + ✓ + +
+
Connected — your AI client just called this server
+
+ Everything works. Ask it anything your connectors can answer. +
+
+
+
+ ); + } + + return ( + +
+ + + + +
+
Waiting for the first request
+
    +
  1. Pick your AI client under Quick Connect and add this server.
  2. +
  3. Ask it to use one of your tools, e.g. “What can you do with my connectors?”
  4. +
  5. This box turns green as soon as the request arrives.
  6. +
+
+
+
+ ); +} diff --git a/packages/frontend/src/components/license-wall.tsx b/packages/frontend/src/components/license-wall.tsx index b2d20051..3ff59d26 100644 --- a/packages/frontend/src/components/license-wall.tsx +++ b/packages/frontend/src/components/license-wall.tsx @@ -7,6 +7,9 @@ import { license } from '@/lib/api'; import { useAuth } from '@/lib/auth-context'; import { usePricingUrl } from '@/lib/use-pricing-url'; import { useManagePlan } from '@/lib/use-manage-plan'; +import { useCardCheckout } from '@/lib/use-card-checkout'; +import { DEFAULT_SELECTION, planById, readPlanIntent, type PlanSelection } from '@/lib/card-trial'; +import { PlanPicker } from '@/components/plan-picker'; import { LogoIcon } from '@/components/logo-icon'; import { buttonVariants } from '@/components/ui/button'; import { cn } from '@/lib/utils'; @@ -17,7 +20,7 @@ import { cn } from '@/lib/utils'; type BlockReason = 'no-license' | 'trial-ended' | 'expired' | 'lapsed'; export function LicenseWall() { - const { token, deploymentMode } = useAuth(); + const { token, user, deploymentMode } = useAuth(); const managePlan = useManagePlan(); const pricingUrl = usePricingUrl(); const [reason, setReason] = useState(null); @@ -25,6 +28,16 @@ export function LicenseWall() { const [startErr, setStartErr] = useState(null); const pathname = usePathname(); const isCloud = deploymentMode === 'cloud'; + const checkout = useCardCheckout(); + const [selection, setSelection] = useState(DEFAULT_SELECTION); + // Cloud admins whose trial ended pick a plan and pay right here (Stripe + // Checkout, no trial left). Other roles cannot subscribe the workspace and + // keep the pricing link. + const buyInPlace = isCloud && reason === 'trial-ended' && user?.role === 'ADMIN'; + + useEffect(() => { + if (buyInPlace) setSelection(readPlanIntent() ?? DEFAULT_SELECTION); + }, [buyInPlace]); // Start the trial in place (no navigation) so a failed auto-activation on // signup doesn't strand the user on this wall. On success the block clears. @@ -120,7 +133,7 @@ export function LicenseWall() { return (
-
+
@@ -151,6 +164,28 @@ export function LicenseWall() { View Plans & Purchase License + ) : buyInPlace ? ( + <> +
+ +
+ + {checkout.error &&

{checkout.error}

} + + Compare plans + + ) : reason === 'lapsed' ? ( { if (isLoading || !token || !user) return; + // Both gates below depend on the deployment mode, which is only a guess + // ('self-hosted') until /health/server-info answers. Evaluating on the + // guess recorded the path as done and never looked again with the real + // mode, so a cloud admin could miss the card-trial offer. + if (!deploymentModeLoaded) return; if (isExcluded(pathname)) return; // Dedupe per (path, userId) to avoid hammering the API on every render. @@ -93,6 +104,22 @@ export function OnboardingRedirect() { const licenseBlocking = isCloud && (noPlan || trialEnded || expired); if (licenseBlocking) return; + // The card-trial offer, once, before the wizard. Marked as shown + // before navigating so a failure on that page can never loop back. + if ( + isCloud && + pathname === '/' && + cardTrialEligible({ isCloud, role: me?.role ?? user.role, license: lic }) && + readCardTrialPrompt(user.id) === null + ) { + writeCardTrialPrompt(user.id, 'shown'); + // Coming back to the dashboard must be evaluated afresh (for the + // wizard), not skipped as already seen. + lastRun.current = null; + router.replace('/start-trial'); + return; + } + const hasConnector = (connList?.length ?? 0) > 0; const wizardDone = onboarding.onboardingCompletedAt !== null; @@ -119,7 +146,7 @@ export function OnboardingRedirect() { return () => { cancelled = true; }; - }, [isLoading, token, user, pathname, deploymentMode, router]); + }, [isLoading, token, user, pathname, deploymentMode, deploymentModeLoaded, router]); return null; } diff --git a/packages/frontend/src/components/plan-picker.tsx b/packages/frontend/src/components/plan-picker.tsx new file mode 100644 index 00000000..cc18df1e --- /dev/null +++ b/packages/frontend/src/components/plan-picker.tsx @@ -0,0 +1,145 @@ +'use client'; + +import { + CLOUD_PLANS, + formatEur, + formatPlanPrice, + yearlyPerMonth, + type BillingPeriod, + type PlanSelection, +} from '@/lib/card-trial'; +import { cn } from '@/lib/utils'; + +/** Monthly / yearly switch. Yearly is ten months' price for twelve. */ +function PeriodToggle({ + value, + onChange, + disabled, +}: { + value: BillingPeriod; + onChange: (p: BillingPeriod) => void; + disabled?: boolean; +}) { + const option = (p: BillingPeriod, label: React.ReactNode) => ( + + ); + return ( +
+ {option('monthly', 'Monthly')} + {option( + 'yearly', + <> + Yearly · 2 months free + , + )} +
+ ); +} + +/** + * The three Cloud plans with a billing-period switch, as a radio group. + * `compact` lays the plans out as rows (for the licence wall's narrow modal). + */ +export function PlanPicker({ + value, + onChange, + compact = false, + disabled = false, +}: { + value: PlanSelection; + onChange: (next: PlanSelection) => void; + compact?: boolean; + disabled?: boolean; +}) { + return ( +
+
+ onChange({ ...value, period })} + disabled={disabled} + /> +
+ +
+ {CLOUD_PLANS.map((plan) => { + const selected = value.plan === plan.id; + return ( + + ); + })} +
+

Prices in EUR, VAT included.

+
+ ); +} diff --git a/packages/frontend/src/components/trial-banner.tsx b/packages/frontend/src/components/trial-banner.tsx index bfd0d9cf..701e17f4 100644 --- a/packages/frontend/src/components/trial-banner.tsx +++ b/packages/frontend/src/components/trial-banner.tsx @@ -1,21 +1,35 @@ 'use client'; import { useState, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; import { license } from '@/lib/api'; import { useAuth } from '@/lib/auth-context'; import { usePricingUrl } from '@/lib/use-pricing-url'; +import { useCardCheckout } from '@/lib/use-card-checkout'; +import { + cardTrialDisplayEnd, + cardTrialEligible, + formatTrialEnd, + readPlanIntent, +} from '@/lib/card-trial'; export function TrialBanner() { - const { token } = useAuth(); + const { token, user, deploymentMode } = useAuth(); + const router = useRouter(); const pricingUrl = usePricingUrl(); + const checkout = useCardCheckout(); const [daysLeft, setDaysLeft] = useState(null); const [plan, setPlan] = useState(null); + const [status, setStatus] = useState(null); + const [expiresAt, setExpiresAt] = useState(null); const [connectors, setConnectors] = useState(null); useEffect(() => { if (!token) return; license.getStatus(token).then((status) => { setPlan(status.plan); + setStatus(status.status); + setExpiresAt(status.expiresAt); if (status.trialDaysLeft !== undefined) { setDaysLeft(status.trialDaysLeft); } @@ -27,7 +41,9 @@ export function TrialBanner() { .catch(() => {}); }, [token]); - if (plan !== 'trial' || daysLeft === null) return null; + // An ended trial is reported too (status 'expired'); the licence wall + // covers that case, so the countdown banner stays out of the way. + if (plan !== 'trial' || daysLeft === null || status !== 'active') return null; const isUrgent = daysLeft <= 1; const isWarning = daysLeft <= 3; @@ -47,6 +63,28 @@ export function TrialBanner() { ? ` Keep your ${connectors} connector${connectors === 1 ? '' : 's'} running —` : ''; + // Cloud admins on a running trial add a card instead (the card trial ends + // with the free trial, nothing is charged before). Everyone else, and + // self-hosted, keeps the pricing link. + const offerCard = cardTrialEligible({ + isCloud: deploymentMode === 'cloud', + role: user?.role, + license: { plan, status: status ?? '', expiresAt, trialDaysLeft: daysLeft }, + }); + const chargeDate = formatTrialEnd(cardTrialDisplayEnd(expiresAt), 'short'); + + const addPaymentMethod = async () => { + // With a plan picked on the pricing page, straight to Checkout; + // otherwise to the plan picker first. + const intent = readPlanIntent(); + if (!intent) { + router.push('/start-trial'); + return; + } + const ok = await checkout.start(intent, true); + if (!ok) window.location.assign(pricingUrl); + }; + return (
{countdown}{value} {' '} - - Upgrade now - + {offerCard ? ( + + ) : ( + + Upgrade now + + )}
); } diff --git a/packages/frontend/src/lib/api.ts b/packages/frontend/src/lib/api.ts index c03baf5a..150fefac 100644 --- a/packages/frontend/src/lib/api.ts +++ b/packages/frontend/src/lib/api.ts @@ -1221,6 +1221,20 @@ export const license = { body: { returnUrl }, token, }), + /** + * Cloud only (ADMIN): a one-time Stripe Checkout URL for a plan. `trial` + * asks for a card trial ending with the free trial; after the trial it is + * a plain purchase. The buyer and workspace come from the session. + */ + checkoutLink: ( + token: string, + body: { plan: 'starter' | 'team' | 'business'; billingPeriod: 'monthly' | 'yearly'; trial: boolean }, + ) => + request<{ url: string }>('/api/license/checkout-link', { + method: 'POST', + body, + token, + }), getInstanceId: () => request<{ instanceId: string }>('/api/license/instance-id'), getUsage: (token?: string) => @@ -1239,6 +1253,9 @@ export const mcpServers = { request('/api/mcp-servers', { token }), get: (id: string, token: string) => request(`/api/mcp-servers/${id}`, { token }), + /** Calls in the last 30 days and the last one's time (the connect page's live check). */ + activity: (id: string, token: string) => + request<{ calls30d: number; lastCallAt: string | null }>(`/api/mcp-servers/${id}/activity`, { token }), create: (data: { name: string; slug?: string; description?: string; instructions?: string }, token: string) => request('/api/mcp-servers', { method: 'POST', body: data, token }), update: (id: string, data: { name?: string; slug?: string; description?: string; instructions?: string; isActive?: boolean }, token: string) => diff --git a/packages/frontend/src/lib/card-trial.ts b/packages/frontend/src/lib/card-trial.ts new file mode 100644 index 00000000..ca079af6 --- /dev/null +++ b/packages/frontend/src/lib/card-trial.ts @@ -0,0 +1,228 @@ +import { storage } from './storage'; + +/** + * The card trial on AnythingMCP Cloud: right after sign-up an admin is offered + * Stripe Checkout with a 7-day trial (card required, nothing charged today), + * ending when their free trial would have. Everything here is Cloud only; + * self-hosted builds never call it. + * + * Pure helpers live here so they can be tested without a browser; the pieces + * that touch storage go through `storage`, which never throws. + */ + +export type CloudPlanId = 'starter' | 'team' | 'business'; +export type BillingPeriod = 'monthly' | 'yearly'; + +export interface PlanSelection { + plan: CloudPlanId; + period: BillingPeriod; +} + +export interface CloudPlan { + id: CloudPlanId; + name: string; + /** EUR incl. VAT per month, on monthly billing. */ + monthly: number; + /** EUR incl. VAT per year, on yearly billing. */ + yearly: number; + summary: string; + popular?: boolean; +} + +/** + * Mirrors anythingmcp.com/pricing and the live Stripe prices (Cloud plans, + * EUR incl. VAT). Keep in step with the marketing site when a price changes: + * the checkout charges what Stripe says, this only describes it. + */ +export const CLOUD_PLANS: readonly CloudPlan[] = [ + { + id: 'starter', + name: 'Starter', + monthly: 19, + yearly: 190, + summary: '5 connectors · 3 MCP servers · 1 user', + }, + { + id: 'team', + name: 'Team', + monthly: 49, + yearly: 490, + summary: '15 connectors · 10 MCP servers · up to 3 users', + popular: true, + }, + { + id: 'business', + name: 'Business', + monthly: 99, + yearly: 990, + summary: 'Unlimited connectors and MCP servers · up to 10 users', + }, +]; + +export const DEFAULT_SELECTION: PlanSelection = { plan: 'team', period: 'monthly' }; + +export function planById(id: CloudPlanId): CloudPlan { + return CLOUD_PLANS.find((p) => p.id === id) ?? CLOUD_PLANS[1]; +} + +/** "19 €", "15.83 €" (non-breaking space, as on the invoice). */ +export function formatEur(amount: number): string { + const text = Number.isInteger(amount) ? String(amount) : amount.toFixed(2); + return `${text} €`; +} + +/** The price line for a plan: "49 €/month" or "490 €/year". */ +export function formatPlanPrice(plan: CloudPlan, period: BillingPeriod): string { + return period === 'yearly' ? `${formatEur(plan.yearly)}/year` : `${formatEur(plan.monthly)}/month`; +} + +/** The monthly equivalent of a yearly price, rounded to the cent: 190 → 15.83. */ +export function yearlyPerMonth(plan: CloudPlan): number { + return Math.round((plan.yearly / 12) * 100) / 100; +} + +/** What yearly billing saves over twelve months of monthly billing. */ +export function yearlySaving(plan: CloudPlan): number { + return plan.monthly * 12 - plan.yearly; +} + +// ── Plan intent from the pricing page ─────────────────────────────────────── + +/** + * The pricing page opens sign-up as `?mode=register&plan=cloud_team&period=yearly`. + * Accepts `cloud_` or the bare tier; anything else is no intent at all. + * A missing or unknown period falls back to monthly. + */ +export function parsePlanIntent( + plan: string | null | undefined, + period: string | null | undefined, +): PlanSelection | null { + if (!plan) return null; + const tier = plan.trim().toLowerCase().replace(/^cloud_/, ''); + if (tier !== 'starter' && tier !== 'team' && tier !== 'business') return null; + const p = (period ?? '').trim().toLowerCase(); + return { plan: tier, period: p === 'yearly' || p === 'annual' ? 'yearly' : 'monthly' }; +} + +const INTENT_KEY = 'amcp_plan_intent'; +/** + * Long enough to survive email verification, which may happen days later and + * in another tab (hence localStorage, not sessionStorage). + */ +export const INTENT_TTL_MS = 7 * 24 * 60 * 60 * 1000; + +export function encodePlanIntent(sel: PlanSelection, now: number = Date.now()): string { + return JSON.stringify({ plan: sel.plan, period: sel.period, savedAt: now }); +} + +/** A stored intent, or null when missing, malformed or older than the TTL. */ +export function decodePlanIntent(raw: string | null, now: number = Date.now()): PlanSelection | null { + if (!raw) return null; + try { + const data = JSON.parse(raw); + if (!data || typeof data !== 'object') return null; + const savedAt = Number(data.savedAt); + if (!Number.isFinite(savedAt) || savedAt > now || now - savedAt > INTENT_TTL_MS) return null; + return parsePlanIntent(String(data.plan ?? ''), String(data.period ?? '')); + } catch { + return null; + } +} + +export function savePlanIntent(sel: PlanSelection): void { + storage.set(INTENT_KEY, encodePlanIntent(sel)); +} + +export function readPlanIntent(): PlanSelection | null { + const raw = storage.get(INTENT_KEY); + const sel = decodePlanIntent(raw); + if (raw && !sel) storage.remove(INTENT_KEY); + return sel; +} + +// ── Eligibility and the one-time prompt ───────────────────────────────────── + +export interface TrialStatusLike { + plan: string | null; + status: string; + expiresAt?: string | null; + trialDaysLeft?: number; +} + +/** + * Whether the card trial is on offer: Cloud, an admin, and a free trial that + * is still running. Other roles cannot start a subscription, and after the + * trial there is nothing left to trial (the licence wall sells the plan). + */ +export function cardTrialEligible(input: { + isCloud: boolean; + role: string | null | undefined; + license: TrialStatusLike | null | undefined; + now?: number; +}): boolean { + const { isCloud, role, license } = input; + if (!isCloud || role !== 'ADMIN' || !license) return false; + if (license.plan !== 'trial' || license.status !== 'active') return false; + // Stripe needs the trial end at least 48 hours out. With less left, the + // trial is not stretched to fit: the regular upgrade (pay now) applies. + if (license.expiresAt) { + const end = new Date(license.expiresAt).getTime(); + return Number.isFinite(end) && end >= (input.now ?? Date.now()) + MIN_CARD_TRIAL_MS; + } + return typeof license.trialDaysLeft === 'number' && license.trialDaysLeft > 2; +} + +/** 'shown': sent to /start-trial once; 'skipped' / 'checkout': chose. */ +export type CardTrialPrompt = 'shown' | 'skipped' | 'checkout'; + +export function cardTrialPromptKey(userId: string): string { + return `amcp_card_trial_prompt:${userId}`; +} + +export function readCardTrialPrompt(userId: string): CardTrialPrompt | null { + const v = storage.get(cardTrialPromptKey(userId)); + return v === 'shown' || v === 'skipped' || v === 'checkout' ? v : null; +} + +export function writeCardTrialPrompt(userId: string, value: CardTrialPrompt): void { + storage.set(cardTrialPromptKey(userId), value); +} + +/** + * Stripe wants a trial to end at least 48 hours out. The card trial ends when + * the free trial does, and is only offered while that is at least this far + * away (the licence site sells without a trial otherwise, rather than + * lengthening it). Mirrors the licence site's rule. + */ +export const MIN_CARD_TRIAL_MS = 48 * 60 * 60 * 1000; + +/** The date the card trial would end (the free trial's end), or null if none is possible. */ +export function cardTrialDisplayEnd( + expiresAt: string | null | undefined, + now: number = Date.now(), +): string | null { + if (!expiresAt) return null; + const end = new Date(expiresAt).getTime(); + if (!Number.isFinite(end) || end < now + MIN_CARD_TRIAL_MS) return null; + return new Date(end).toISOString(); +} + +/** + * The trial end as shown to the user: "7 October 2026" (long) or "7 Oct" + * (short), in their own time zone. English, like the rest of the UI. + */ +export function formatTrialEnd( + iso: string | null | undefined, + style: 'long' | 'short' = 'long', + timeZone?: string, +): string | null { + if (!iso) return null; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return null; + return d.toLocaleDateString('en-GB', { + day: 'numeric', + month: style === 'long' ? 'long' : 'short', + ...(style === 'long' && { year: 'numeric' }), + ...(timeZone && { timeZone }), + }); +} diff --git a/packages/frontend/src/lib/use-card-checkout.ts b/packages/frontend/src/lib/use-card-checkout.ts new file mode 100644 index 00000000..90772d11 --- /dev/null +++ b/packages/frontend/src/lib/use-card-checkout.ts @@ -0,0 +1,47 @@ +'use client'; + +import { useCallback, useState } from 'react'; +import { ApiError, license } from './api'; +import { useAuth } from './auth-context'; +import type { PlanSelection } from './card-trial'; + +const GENERIC_ERROR = 'We could not open the checkout. Please try again in a moment.'; + +/** + * Opens Stripe Checkout for a Cloud plan (POST /api/license/checkout-link), + * then leaves the page for it. `trial: true` is the card trial that ends with + * the free trial; `false` is a plain purchase. Resolves false on failure so a + * caller can fall back (e.g. to the pricing page); `error` holds a message + * fit to show. + */ +export function useCardCheckout() { + const { token } = useAuth(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const start = useCallback( + async (selection: PlanSelection, trial: boolean): Promise => { + if (!token) return false; + setLoading(true); + setError(null); + try { + const { url } = await license.checkoutLink(token, { + plan: selection.plan, + billingPeriod: selection.period, + trial, + }); + // Stays "loading" on purpose: the page is being left. + window.location.assign(url); + return true; + } catch (err) { + // A 409 says why (the workspace already pays); anything else is ours. + setError(err instanceof ApiError && err.status === 409 ? err.message : GENERIC_ERROR); + setLoading(false); + return false; + } + }, + [token], + ); + + return { start, loading, error }; +} diff --git a/packages/frontend/tests/e2e/card-trial.spec.ts b/packages/frontend/tests/e2e/card-trial.spec.ts new file mode 100644 index 00000000..ec7a3e21 --- /dev/null +++ b/packages/frontend/tests/e2e/card-trial.spec.ts @@ -0,0 +1,267 @@ +import { expect, test, type Page } from '@playwright/test'; +import { + CLOUD_PLANS, + INTENT_TTL_MS, + cardTrialDisplayEnd, + cardTrialEligible, + decodePlanIntent, + encodePlanIntent, + formatPlanPrice, + formatTrialEnd, + parsePlanIntent, + yearlyPerMonth, + yearlySaving, +} from '../../src/lib/card-trial'; + +/** + * The card trial on AnythingMCP Cloud: right after sign-up an admin on a + * running free trial is offered Stripe Checkout with a trial (nothing charged + * today), once, with a quiet "Continue without payment details" link in the + * corner that keeps the no-card trial. + * + * The first block tests the pure helpers (no browser). The rest uses the same + * fake session as the other specs: seed the cookie and localStorage, stub + * /api/* and /health/*. + */ + +test.describe('card-trial helpers', () => { + test('reads the plan the pricing page sends', () => { + expect(parsePlanIntent('cloud_team', 'yearly')).toEqual({ plan: 'team', period: 'yearly' }); + expect(parsePlanIntent('cloud_starter', null)).toEqual({ plan: 'starter', period: 'monthly' }); + expect(parsePlanIntent('business', 'weekly')).toEqual({ plan: 'business', period: 'monthly' }); + expect(parsePlanIntent('cloud_enterprise', 'monthly')).toBeNull(); + expect(parsePlanIntent(null, 'yearly')).toBeNull(); + }); + + test('keeps a stored intent for seven days and no longer', () => { + const now = Date.UTC(2026, 9, 1); + const raw = encodePlanIntent({ plan: 'business', period: 'yearly' }, now); + expect(decodePlanIntent(raw, now + INTENT_TTL_MS - 1)).toEqual({ plan: 'business', period: 'yearly' }); + expect(decodePlanIntent(raw, now + INTENT_TTL_MS + 1)).toBeNull(); + expect(decodePlanIntent('not json', now)).toBeNull(); + expect(decodePlanIntent(JSON.stringify({ plan: 'team', period: 'monthly' }), now)).toBeNull(); + expect(decodePlanIntent(null, now)).toBeNull(); + }); + + test('offers the card trial to cloud admins on a running trial only', () => { + const now = Date.UTC(2026, 9, 1); + const trial = { plan: 'trial', status: 'active', expiresAt: new Date(now + 3 * 86_400_000).toISOString() }; + expect(cardTrialEligible({ isCloud: true, role: 'ADMIN', license: trial, now })).toBe(true); + expect(cardTrialEligible({ isCloud: false, role: 'ADMIN', license: trial, now })).toBe(false); + expect(cardTrialEligible({ isCloud: true, role: 'EDITOR', license: trial, now })).toBe(false); + expect( + cardTrialEligible({ isCloud: true, role: 'ADMIN', license: { ...trial, expiresAt: new Date(now - 1).toISOString() }, now }), + ).toBe(false); + expect(cardTrialEligible({ isCloud: true, role: 'ADMIN', license: { ...trial, plan: 'cloud_team' }, now })).toBe(false); + expect(cardTrialEligible({ isCloud: true, role: 'ADMIN', license: null, now })).toBe(false); + // Under 48 hours left: no card trial (it would have to be lengthened). + expect( + cardTrialEligible({ isCloud: true, role: 'ADMIN', license: { ...trial, expiresAt: new Date(now + 86_400_000).toISOString() }, now }), + ).toBe(false); + }); + + test('the card trial ends with the free trial, and needs 48 hours of it left', () => { + const now = Date.UTC(2026, 9, 1, 12); + const inFiveDays = new Date(now + 5 * 86_400_000).toISOString(); + expect(cardTrialDisplayEnd(inFiveDays, now)).toBe(inFiveDays); + expect(cardTrialDisplayEnd(new Date(now + 3_600_000).toISOString(), now)).toBeNull(); + expect(cardTrialDisplayEnd(null, now)).toBeNull(); + }); + + test('formats the trial end and the prices', () => { + expect(formatTrialEnd('2026-10-07T09:00:00.000Z', 'long', 'UTC')).toBe('7 October 2026'); + expect(formatTrialEnd('2026-10-07T09:00:00.000Z', 'short', 'UTC')).toBe('7 Oct'); + expect(formatTrialEnd('nonsense')).toBeNull(); + + const [starter, team, business] = CLOUD_PLANS; + expect(formatPlanPrice(team, 'monthly')).toBe('49 €/month'); + expect(formatPlanPrice(starter, 'yearly')).toBe('190 €/year'); + expect(yearlyPerMonth(starter)).toBe(15.83); + expect(yearlyPerMonth(business)).toBe(82.5); + expect(CLOUD_PLANS.map(yearlySaving)).toEqual([38, 98, 198]); + }); +}); + +const ADMIN = { + id: 'u1', + email: 'admin@example.test', + name: 'Ada Admin', + role: 'ADMIN', + organizationId: 'o1', + emailVerified: true, +}; + +const TRIAL_END = new Date(Date.now() + 5 * 86_400_000).toISOString(); + +async function cloudSession( + page: Page, + opts: { + user?: typeof ADMIN; + seed?: Record; + license?: Record; + checkout?: (body: unknown) => { status: number; body: unknown }; + } = {}, +) { + const user = opts.user ?? ADMIN; + const posted: unknown[] = []; + const origin = test.info().project.use.baseURL ?? 'http://localhost:3100'; + await page.context().addCookies([{ name: 'amcp_token', value: 't', url: origin }]); + await page.addInitScript( + ({ user, seed }) => { + localStorage.setItem('amcp_token', 't'); + localStorage.setItem('amcp_user', JSON.stringify(user)); + for (const [k, v] of Object.entries(seed)) localStorage.setItem(k, v); + }, + { user, seed: opts.seed ?? {} }, + ); + await page.route(/\/(api|health)\//, async (route) => { + const req = route.request(); + const p = new URL(req.url()).pathname; + const json = (body: unknown, status = 200) => + route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) }); + if (p === '/health/server-info') { + return json({ deploymentMode: 'cloud', mcpAuthMode: 'oauth2', hasUsers: true, registrationEnabled: true, ssoProviders: [] }); + } + if (p.endsWith('/license/checkout-link') && req.method() === 'POST') { + const body = req.postDataJSON(); + posted.push(body); + const reply = opts.checkout + ? opts.checkout(body) + : { status: 200, body: { url: 'https://checkout.example.test/start?intent=i1' } }; + return json(reply.body, reply.status); + } + if (p.endsWith('/users/me/onboarding-state')) return json({ onboardingCompletedAt: null }); + if (p.endsWith('/users/me')) return json(user); + if (p.endsWith('/license/status')) { + return json( + opts.license ?? { plan: 'trial', status: 'active', expiresAt: TRIAL_END, trialDaysLeft: 5, features: {} }, + ); + } + if (p.endsWith('/license/usage')) { + return json({ plan: 'trial', connectors: { current: 0, max: 2, isOver: false }, mcpServers: { current: 0, max: 2, isOver: false }, users: { current: 1, max: 1, isOver: false }, isOverAny: false }); + } + if (p.endsWith('/adapters/starter-pack')) return json([]); + if (p.endsWith('/connectors') || p.endsWith('/adapters')) return json([]); + return json({}); + }); + await page.route('https://checkout.example.test/**', (route) => + route.fulfill({ status: 200, contentType: 'text/html', body: '

Stripe Checkout stub

' }), + ); + return { posted }; +} + +test.describe('card-trial offer (cloud)', () => { + test('a new cloud admin is offered the card trial once, and can skip it quietly', async ({ page }) => { + await cloudSession(page); + await page.goto('/'); + await expect(page).toHaveURL(/\/start-trial$/, { timeout: 15_000 }); + await expect(page.getByRole('heading', { name: 'Your 7-day free trial' })).toBeVisible(); + await expect(page.getByText('€0 today.')).toBeVisible(); + // Team is preselected without an intent from the pricing page. + await expect(page.getByRole('radio', { name: /Team/ })).toHaveAttribute('aria-checked', 'true'); + + // The skip is a small link, not a button styled as one. + const skip = page.getByRole('button', { name: 'Continue without payment details' }); + await expect(skip).toHaveClass(/text-xs/); + await skip.click(); + + // On to the normal onboarding, and not offered again. + await expect(page).toHaveURL(/\/welcome$/, { timeout: 15_000 }); + expect(await page.evaluate(() => localStorage.getItem('amcp_card_trial_prompt:u1'))).toBe('skipped'); + await page.goto('/'); + await expect(page).toHaveURL(/\/welcome$/, { timeout: 15_000 }); + }); + + test('Start free trial opens Checkout for the chosen plan with a trial', async ({ page }) => { + const { posted } = await cloudSession(page, { seed: { 'amcp_card_trial_prompt:u1': 'shown' } }); + await page.goto('/start-trial'); + await page.getByRole('radio', { name: 'Yearly' }).click(); + await page.getByRole('radio', { name: /Starter/ }).click(); + await expect(page.getByText('190 €/year')).toBeVisible(); + await page.getByRole('button', { name: 'Start free trial' }).click(); + + await expect(page).toHaveURL('https://checkout.example.test/start?intent=i1'); + expect(posted).toEqual([{ plan: 'starter', billingPeriod: 'yearly', trial: true }]); + }); + + test('a failed checkout shows a friendly error and can be retried', async ({ page }) => { + let calls = 0; + await cloudSession(page, { + seed: { 'amcp_card_trial_prompt:u1': 'shown' }, + checkout: () => + ++calls === 1 + ? { status: 502, body: { message: 'upstream detail' } } + : { status: 200, body: { url: 'https://checkout.example.test/start?intent=i2' } }, + }); + await page.goto('/start-trial'); + await page.getByRole('button', { name: 'Start free trial' }).click(); + const alert = page.getByRole('alert').filter({ hasText: 'checkout' }); + await expect(alert).toContainText('could not open the checkout'); + await expect(alert).not.toContainText('upstream detail'); + await page.getByRole('button', { name: 'Try again' }).click(); + await expect(page).toHaveURL('https://checkout.example.test/start?intent=i2'); + }); + + test('the plan picked on the pricing page survives sign-up and is preselected', async ({ page }) => { + await cloudSession(page, { seed: { 'amcp_card_trial_prompt:u1': 'shown' } }); + // Signed out on the register page first (the cookie is irrelevant there). + await page.goto('/login?mode=register&plan=cloud_business&period=yearly'); + await expect + .poll(() => page.evaluate(() => localStorage.getItem('amcp_plan_intent')), { timeout: 15_000 }) + .toContain('"plan":"business"'); + + await page.goto('/start-trial'); + await expect(page.getByRole('radio', { name: /Business/ })).toHaveAttribute('aria-checked', 'true'); + await expect(page.getByRole('radio', { name: 'Yearly' })).toHaveAttribute('aria-checked', 'true'); + }); + + test('a non-admin is never shown the offer', async ({ page }) => { + await cloudSession(page, { user: { ...ADMIN, role: 'EDITOR' } }); + await page.goto('/start-trial'); + await expect(page).not.toHaveURL(/\/start-trial$/, { timeout: 15_000 }); + }); +}); + +test.describe('card-trial entry points (cloud)', () => { + const SHOWN = { 'amcp_card_trial_prompt:u1': 'shown' }; + + test('the trial banner offers a card and, without a picked plan, opens the picker', async ({ page }) => { + const { posted } = await cloudSession(page, { seed: SHOWN }); + await page.goto('/connectors'); + const cta = page.getByRole('button', { name: /Add a payment method — no charge before/ }); + await expect(cta).toBeVisible({ timeout: 15_000 }); + await cta.click(); + await expect(page).toHaveURL(/\/start-trial$/); + expect(posted).toEqual([]); + }); + + test('the trial banner goes straight to Checkout for the plan picked on the pricing page', async ({ page }) => { + const intent = JSON.stringify({ plan: 'business', period: 'monthly', savedAt: Date.now() }); + const { posted } = await cloudSession(page, { seed: { ...SHOWN, amcp_plan_intent: intent } }); + await page.goto('/connectors'); + await page.getByRole('button', { name: /Add a payment method/ }).click({ timeout: 15_000 }); + await expect(page).toHaveURL('https://checkout.example.test/start?intent=i1'); + expect(posted).toEqual([{ plan: 'business', billingPeriod: 'monthly', trial: true }]); + }); + + test('after the trial, an admin buys the chosen plan from the licence wall, without a trial', async ({ page }) => { + const { posted } = await cloudSession(page, { + seed: SHOWN, + license: { plan: 'trial', status: 'active', expiresAt: new Date(Date.now() - 86_400_000).toISOString(), trialDaysLeft: 0 }, + }); + await page.goto('/connectors'); + await expect(page.getByRole('heading', { name: 'Your Trial Has Expired' })).toBeVisible({ timeout: 15_000 }); + await expect(page.getByRole('link', { name: 'Compare plans' })).toBeVisible(); + await page.getByRole('radio', { name: /Starter/ }).click(); + await page.getByRole('button', { name: 'Subscribe to Starter' }).click(); + await expect(page).toHaveURL('https://checkout.example.test/start?intent=i1'); + expect(posted).toEqual([{ plan: 'starter', billingPeriod: 'monthly', trial: false }]); + }); + + test('a non-admin keeps the plain pricing link in the banner', async ({ page }) => { + await cloudSession(page, { user: { ...ADMIN, role: 'EDITOR' }, seed: SHOWN }); + await page.goto('/connectors'); + await expect(page.getByRole('link', { name: 'Upgrade now' })).toBeVisible({ timeout: 15_000 }); + await expect(page.getByRole('button', { name: /Add a payment method/ })).toHaveCount(0); + }); +});