diff --git a/apps/api/src/controllers/Domains.ts b/apps/api/src/controllers/Domains.ts index 8a2f46e..5f101a4 100644 --- a/apps/api/src/controllers/Domains.ts +++ b/apps/api/src/controllers/Domains.ts @@ -4,7 +4,7 @@ import type {NextFunction, Request, Response} from 'express'; import {redis} from '../database/redis.js'; import {NotAllowed, NotFound} from '../exceptions/index.js'; -import {isAuthenticated, requireEmailVerified} from '../middleware/auth.js'; +import {requireAuth, requireEmailVerified} from '../middleware/auth.js'; import {DomainService} from '../services/DomainService.js'; import {Keys} from '../services/keys.js'; import {MembershipService} from '../services/MembershipService.js'; @@ -17,16 +17,12 @@ export class Domains { * Get all domains for a project */ @Get('project/:projectId') - @Middleware([isAuthenticated, requireEmailVerified]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync - public async getProjectDomains(req: Request, res: Response, _next: NextFunction) { + public async getProjectDomains(_req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth; - const {projectId} = DomainSchemas.projectId.parse(req.params); - // Verify user has access to this project - await MembershipService.requireAccess(auth.userId!, projectId); - - const domains = await DomainService.getProjectDomains(projectId); + const domains = await DomainService.getProjectDomains(auth.projectId!); return res.status(200).json(domains); } @@ -35,19 +31,18 @@ export class Domains { * Add a new domain to a project */ @Post('') - @Middleware([isAuthenticated, requireEmailVerified]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async addDomain(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth; - const {projectId, domain} = DomainSchemas.create.parse(req.body); + const {domain} = DomainSchemas.create.parse(req.body); + const projectId = auth.projectId!; - if (!auth.userId) { - throw new NotFound('User authentication required'); + // Require admin role for JWT users (API keys bypass — project-scoped by design) + if (auth.type === 'jwt') { + await MembershipService.requireAdminAccess(auth.userId!, projectId); } - // Verify user has admin access to this project - await MembershipService.requireAdminAccess(auth.userId!, projectId); - // Block domain changes on disabled projects const isDisabled = await SecurityService.isProjectDisabled(projectId); if (isDisabled) { @@ -68,6 +63,12 @@ export class Domains { const ownershipCheck = await DomainService.checkDomainOwnership(domain, auth.userId); if (ownershipCheck.exists) { + if (ownershipCheck.projectId === projectId) { + return res.status(400).json({ + error: 'This domain is already linked to this project.', + }); + } + // If domain exists and user is a member of that project, allow it if (ownershipCheck.isMember) { return res.status(400).json({ @@ -99,7 +100,7 @@ export class Domains { * Check verification status for a domain */ @Get(':id/verify') - @Middleware([isAuthenticated, requireEmailVerified]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async checkVerification(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth; @@ -107,13 +108,10 @@ export class Domains { const domain = await DomainService.id(id); - if (!domain) { + if (!domain || domain.projectId !== auth.projectId) { throw new NotFound('Domain not found'); } - // Verify user has access to the project this domain belongs to - await MembershipService.requireAccess(auth.userId!, domain.projectId); - const verificationStatus = await DomainService.checkVerification(id); // Invalidate cache if status changed @@ -127,7 +125,7 @@ export class Domains { * Remove a domain from a project */ @Delete(':id') - @Middleware([isAuthenticated, requireEmailVerified]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async removeDomain(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth; @@ -135,12 +133,14 @@ export class Domains { const domain = await DomainService.id(id); - if (!domain) { + if (!domain || domain.projectId !== auth.projectId) { throw new NotFound('Domain not found'); } - // Verify user has admin access to the project this domain belongs to - await MembershipService.requireAdminAccess(auth.userId!, domain.projectId); + // Require admin role for JWT users (API keys bypass — project-scoped by design) + if (auth.type === 'jwt') { + await MembershipService.requireAdminAccess(auth.userId!, domain.projectId); + } // Block domain changes on disabled projects const isDisabled = await SecurityService.isProjectDisabled(domain.projectId); diff --git a/apps/api/src/services/DomainService.ts b/apps/api/src/services/DomainService.ts index da2562d..17189ab 100644 --- a/apps/api/src/services/DomainService.ts +++ b/apps/api/src/services/DomainService.ts @@ -438,15 +438,14 @@ export class DomainService { * @param userId User ID to check membership * @returns Object with exists flag and membership info */ - public static async checkDomainOwnership(domain: string, userId: string) { + public static async checkDomainOwnership(domain: string, userId?: string) { const existingDomain = await prisma.domain.findFirst({ where: {domain}, include: { project: { - include: { - members: { - where: {userId}, - }, + select: { + id: true, + name: true, }, }, }, @@ -456,8 +455,20 @@ export class DomainService { return {exists: false}; } - // Check if user is a member of the project that owns this domain - const isMember = existingDomain.project.members.length > 0; + let isMember = false; + + if (userId) { + const membership = await prisma.membership.findUnique({ + where: { + userId_projectId: { + userId, + projectId: existingDomain.project.id, + }, + }, + }); + + isMember = membership !== null; + } return { exists: true,