From 3f30a48c406bab3f145bc5e174525e985616eb50 Mon Sep 17 00:00:00 2001 From: ReylanLugo Date: Sat, 9 May 2026 22:25:17 -0400 Subject: [PATCH] feat(api): allow API key authentication for domain endpoints Switch /domains controller from `isAuthenticated` (cookie-only) to `requireAuth` (cookie OR API key), matching the pattern used by other project-scoped API endpoints (/v1/send, /contacts, etc.). API keys are project-scoped credentials with full access; for write operations the projectId in the request must equal the API key's projectId. JWT (dashboard) auth retains role-based checks (requireAdminAccess for POST/DELETE). Also: improve UX when a domain is already linked to the same project by returning a clear error instead of the generic "linked to another project" message. Refactor DomainService.checkDomainOwnership to make `userId` optional (needed for API key path) while preserving its existing return shape and adding `projectId` to the result. --- apps/api/src/controllers/Domains.ts | 57 ++++++++++++++++++-------- apps/api/src/services/DomainService.ts | 25 +++++++---- 2 files changed, 59 insertions(+), 23 deletions(-) diff --git a/apps/api/src/controllers/Domains.ts b/apps/api/src/controllers/Domains.ts index 8a2f46e..a87e443 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,14 +17,19 @@ 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) { 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); + if (auth.type === 'apiKey') { + if (auth.projectId !== projectId) { + throw new NotAllowed('You do not have access to this project'); + } + } else { + await MembershipService.requireAccess(auth.userId!, projectId); + } const domains = await DomainService.getProjectDomains(projectId); @@ -35,19 +40,23 @@ 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 {projectId: requestedProjectId, domain} = DomainSchemas.create.parse(req.body); + const projectId = auth.type === 'apiKey' ? auth.projectId : requestedProjectId; - if (!auth.userId) { + if (auth.type === 'apiKey') { + if (requestedProjectId !== auth.projectId) { + throw new NotAllowed('You do not have access to this project'); + } + } else if (!auth.userId) { throw new NotFound('User authentication required'); + } else { + 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 +77,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 +114,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; @@ -111,8 +126,13 @@ export class Domains { throw new NotFound('Domain not found'); } - // Verify user has access to the project this domain belongs to - await MembershipService.requireAccess(auth.userId!, domain.projectId); + if (auth.type === 'apiKey') { + if (auth.projectId !== domain.projectId) { + throw new NotAllowed('You do not have access to this project'); + } + } else { + await MembershipService.requireAccess(auth.userId!, domain.projectId); + } const verificationStatus = await DomainService.checkVerification(id); @@ -127,7 +147,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; @@ -139,8 +159,13 @@ export class Domains { 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); + if (auth.type === 'apiKey') { + if (auth.projectId !== domain.projectId) { + throw new NotAllowed('You do not have access to this project'); + } + } else { + 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,