Skip to content
Merged
6 changes: 6 additions & 0 deletions packages/backend/src/audit/product-event.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
41 changes: 40 additions & 1 deletion packages/backend/src/auth/auth.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
});
});
22 changes: 17 additions & 5 deletions packages/backend/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>('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;
}
}
}

Expand Down
62 changes: 62 additions & 0 deletions packages/backend/src/auth/email-verified.guard.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
27 changes: 24 additions & 3 deletions packages/backend/src/auth/email-verified.guard.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
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
* non-auth endpoint. Self-hosted deployments are unaffected.
*
* 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 {
Expand All @@ -27,23 +33,38 @@ 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<boolean> {
const isCloud = this.configService.get<string>('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))) {
return true;
}

const dbUser = await this.prisma.user.findUnique({
where: { id: user.sub },
where: { id: userId },
select: { emailVerified: true },
});
if (!dbUser?.emailVerified) {
Expand Down
Loading
Loading