From 3f30a48c406bab3f145bc5e174525e985616eb50 Mon Sep 17 00:00:00 2001 From: ReylanLugo Date: Sat, 9 May 2026 22:25:17 -0400 Subject: [PATCH 1/2] 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, From 6aee5db588183c468e06fab64ca5c9af5b79568d Mon Sep 17 00:00:00 2001 From: ReylanLugo Date: Sun, 10 May 2026 22:44:28 -0400 Subject: [PATCH 2/2] refactor(api): rely on auth middleware for domain endpoint permissions Replace per-route apiKey/jwt branching with auth.projectId from middleware, matching the contacts controller pattern. Preserve JWT admin gating on POST/DELETE; API keys are project-scoped by design and skip the role check. Cross-project domain access by ID now returns 404 instead of 403 to avoid leaking existence. --- apps/api/src/controllers/Domains.ts | 47 +++++++---------------------- 1 file changed, 11 insertions(+), 36 deletions(-) diff --git a/apps/api/src/controllers/Domains.ts b/apps/api/src/controllers/Domains.ts index a87e443..5f101a4 100644 --- a/apps/api/src/controllers/Domains.ts +++ b/apps/api/src/controllers/Domains.ts @@ -19,19 +19,10 @@ export class Domains { @Get('project/:projectId') @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); - 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); + const domains = await DomainService.getProjectDomains(auth.projectId!); return res.status(200).json(domains); } @@ -44,17 +35,12 @@ export class Domains { @CatchAsync public async addDomain(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth; - const {projectId: requestedProjectId, domain} = DomainSchemas.create.parse(req.body); - const projectId = auth.type === 'apiKey' ? auth.projectId : requestedProjectId; + const {domain} = DomainSchemas.create.parse(req.body); + const projectId = auth.projectId!; - 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); + // Require admin role for JWT users (API keys bypass — project-scoped by design) + if (auth.type === 'jwt') { + await MembershipService.requireAdminAccess(auth.userId!, projectId); } // Block domain changes on disabled projects @@ -122,18 +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'); } - 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); // Invalidate cache if status changed @@ -155,15 +133,12 @@ export class Domains { const domain = await DomainService.id(id); - if (!domain) { + if (!domain || domain.projectId !== auth.projectId) { throw new NotFound('Domain not found'); } - if (auth.type === 'apiKey') { - if (auth.projectId !== domain.projectId) { - throw new NotAllowed('You do not have access to this project'); - } - } else { + // Require admin role for JWT users (API keys bypass — project-scoped by design) + if (auth.type === 'jwt') { await MembershipService.requireAdminAccess(auth.userId!, domain.projectId); }