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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Database
DATABASE_URL="postgresql://user:password@localhost:5432/cortex"

# Google OAuth 2.0
# Create credentials at https://console.cloud.google.com/apis/credentials
GOOGLE_CLIENT_ID="your-google-client-id.apps.googleusercontent.com"
GOOGLE_CLIENT_SECRET="your-google-client-secret"
GOOGLE_CALLBACK_URL="http://localhost:4000/auth/google/callback"

# JWT
# Use a long, random secret in production: `openssl rand -base64 64`
JWT_SECRET="changeme-dev-secret"
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ name: CI

on:
push:
branches: [main]
branches: [main, develop]
pull_request:
branches: [main]
branches: [main, develop]

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
Expand Down
2 changes: 2 additions & 0 deletions apps/api/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,7 @@ module.exports = {
testEnvironment: "node",
moduleNameMapper: {
"^@cortex/shared$": "<rootDir>/../../../packages/shared/src/index.ts",
"^db/client$": "<rootDir>/__mocks__/db-client.mock.ts",
"^db$": "<rootDir>/__mocks__/db-client.mock.ts",
},
};
10 changes: 10 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,15 @@
"@modelcontextprotocol/sdk": "^1.29.0",
"@nestjs/common": "^11.1.21",
"@nestjs/core": "^11.1.21",
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.1.21",
"db": "workspace:*",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"passport": "^0.7.0",
"passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1",
"zod": "^4.4.3"
},
"devDependencies": {
Expand All @@ -32,7 +38,11 @@
"@nestjs/testing": "^11.1.24",
"@types/express": "^5.0.3",
"@types/jest": "^30.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^22.15.3",
"@types/passport": "^1.0.17",
"@types/passport-google-oauth20": "^2.0.17",
"@types/passport-jwt": "^4.0.1",
"eslint": "^9.39.1",
"jest": "^30.4.2",
"ts-jest": "^29.4.11",
Expand Down
29 changes: 29 additions & 0 deletions apps/api/src/__mocks__/db-client.mock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* Jest manual mock for the Prisma generated client (`db/client`).
*
* Used in all unit and integration tests so that module resolution doesn't
* attempt to load the ESM-only generated Prisma files.
*/

export const mockPrismaClient = {
$connect: jest.fn().mockResolvedValue(undefined),
$disconnect: jest.fn().mockResolvedValue(undefined),
user: {
findUnique: jest.fn(),
create: jest.fn(),
upsert: jest.fn(),
},
organization: {
findUnique: jest.fn(),
findFirst: jest.fn(),
},
};

export class PrismaClient {
$connect = mockPrismaClient.$connect;
$disconnect = mockPrismaClient.$disconnect;
user = mockPrismaClient.user;
organization = mockPrismaClient.organization;
}

export const Prisma = {};
58 changes: 52 additions & 6 deletions apps/api/src/app.module.spec.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,41 @@
import { Test } from "@nestjs/testing";
import { AppModule } from "./app.module";
import { MCPController } from "./mcp/mcp.controller";
import { GoogleStrategy } from "./auth/strategies/google.strategy";
import { PrismaService } from "./prisma/prisma.service";

/** Stub that replaces GoogleStrategy so tests don't need real OAuth credentials. */
class MockGoogleStrategy {
name = "google";
}

/** Stub that replaces PrismaService so tests don't need a real database. */
class MockPrismaService {
$connect = jest.fn().mockResolvedValue(undefined);
$disconnect = jest.fn().mockResolvedValue(undefined);
user = { findUnique: jest.fn(), create: jest.fn() };
organization = { findFirst: jest.fn() };
}

describe("AppModule", () => {
beforeAll(() => {
// AuthModule.registerAsync and JwtStrategy both require JWT_SECRET at startup.
process.env["JWT_SECRET"] = "test-secret-for-app-module-spec";
});

afterAll(() => {
delete process.env["JWT_SECRET"];
});

it("should be defined and compile successfully", async () => {
const module = await Test.createTestingModule({
imports: [AppModule],
}).compile();
})
.overrideProvider(GoogleStrategy)
.useClass(MockGoogleStrategy)
.overrideProvider(PrismaService)
.useClass(MockPrismaService)
.compile();

expect(module).toBeDefined();
});
Expand All @@ -15,14 +44,24 @@ describe("AppModule", () => {
await expect(
Test.createTestingModule({
imports: [AppModule],
}).compile()
})
.overrideProvider(GoogleStrategy)
.useClass(MockGoogleStrategy)
.overrideProvider(PrismaService)
.useClass(MockPrismaService)
.compile(),
).resolves.not.toThrow();
});

it("should provide MCPController via imported MCPModule", async () => {
const module = await Test.createTestingModule({
imports: [AppModule],
}).compile();
})
.overrideProvider(GoogleStrategy)
.useClass(MockGoogleStrategy)
.overrideProvider(PrismaService)
.useClass(MockPrismaService)
.compile();

const controller = module.get(MCPController);
expect(controller).toBeDefined();
Expand All @@ -32,7 +71,12 @@ describe("AppModule", () => {
it("should wire MCPController so handleMCP is callable", async () => {
const module = await Test.createTestingModule({
imports: [AppModule],
}).compile();
})
.overrideProvider(GoogleStrategy)
.useClass(MockGoogleStrategy)
.overrideProvider(PrismaService)
.useClass(MockPrismaService)
.compile();

const controller = module.get(MCPController);
const response = await controller.handleMCP({
Expand All @@ -42,6 +86,8 @@ describe("AppModule", () => {
query: "integration check",
});

expect(response.answer).toBe("Based on company standards: integration check");
expect(response.answer).toBe(
"Based on company standards: integration check",
);
});
});
});
4 changes: 3 additions & 1 deletion apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { Module } from "@nestjs/common";
import { MCPModule } from "./mcp/mcp.module";
import { AuthModule } from "./auth/auth.module";
import { PrismaModule } from "./prisma/prisma.module";

@Module({
imports: [MCPModule],
imports: [PrismaModule, AuthModule, MCPModule],
})
export class AppModule {}
77 changes: 77 additions & 0 deletions apps/api/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import {
Controller,
Get,
Req,
Res,
UseGuards,
HttpCode,
HttpStatus,
} from "@nestjs/common";
import type { Request, Response } from "express";
import { AuthService } from "./auth.service";
import { UserService } from "./user.service";
import { GoogleAuthGuard } from "./guards/google-auth.guard";
import { JwtAuthGuard } from "./guards/jwt-auth.guard";
import type { AuthenticatedUser } from "./auth.types";

interface RequestWithUser extends Request {
user: AuthenticatedUser & { googleSub: string };
}

interface RequestWithJwtUser extends Request {
user: AuthenticatedUser;
}

@Controller("auth")
export class AuthController {
constructor(
private readonly authService: AuthService,
private readonly userService: UserService,
) {}

/** Initiates the Google OAuth consent screen redirect. */
@Get("google")
@UseGuards(GoogleAuthGuard)
googleLogin(): void {
// Guard redirects to Google – no body needed.
}

/**
* Google calls back here after the user grants consent.
* findOrCreate throws UnauthorizedException when no organization is
* provisioned for the user's email domain, so a JWT is only issued after
* a valid organizationId is confirmed.
*/
@Get("google/callback")
@UseGuards(GoogleAuthGuard)
async googleCallback(
@Req() req: RequestWithUser,
@Res() res: Response,
): Promise<void> {
const { googleSub, email } = req.user;

const dbUser = await this.userService.findOrCreate({ googleSub, email });

const authenticatedUser: AuthenticatedUser = {
id: dbUser.id,
email: dbUser.email,
organizationId: dbUser.organizationId,
role: dbUser.role,
};

const token = this.authService.issueToken(authenticatedUser);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

res.json({ accessToken: token });
}
}

@Controller("api")
export class MeController {
/** Returns the identity of the currently authenticated user. */
@Get("me")
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
getMe(@Req() req: RequestWithJwtUser): AuthenticatedUser {
return req.user;
}
}
27 changes: 27 additions & 0 deletions apps/api/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Module } from "@nestjs/common";
import { JwtModule } from "@nestjs/jwt";
import { PassportModule } from "@nestjs/passport";
import { AuthService } from "./auth.service";
import { UserService } from "./user.service";
import { AuthController, MeController } from "./auth.controller";
import { GoogleStrategy } from "./strategies/google.strategy";
import { JwtStrategy } from "./strategies/jwt.strategy";

@Module({
imports: [
PassportModule.register({ defaultStrategy: "jwt" }),
JwtModule.registerAsync({
useFactory: () => {
const secret = process.env["JWT_SECRET"];
if (!secret) {
throw new Error("JWT_SECRET environment variable is required");
}
return { secret, signOptions: { expiresIn: "8h" } };
},
}),
],
controllers: [AuthController, MeController],
providers: [AuthService, UserService, GoogleStrategy, JwtStrategy],
exports: [AuthService, JwtModule],
})
export class AuthModule {}
Loading
Loading