Skip to content
2 changes: 2 additions & 0 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { RisksModule } from './risks/risks.module';
import { TasksModule } from './tasks/tasks.module';
import { VendorsModule } from './vendors/vendors.module';
import { ContextModule } from './context/context.module';
import { TrustPortalModule } from './trust-portal/trust-portal.module';

@Module({
imports: [
Expand All @@ -41,6 +42,7 @@ import { ContextModule } from './context/context.module';
TasksModule,
CommentsModule,
HealthModule,
TrustPortalModule,
],
controllers: [AppController],
providers: [AppService],
Expand Down
14 changes: 13 additions & 1 deletion apps/api/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { INestApplication } from '@nestjs/common';
import { VersioningType } from '@nestjs/common';
import { ValidationPipe, VersioningType } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import type { OpenAPIObject } from '@nestjs/swagger';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
Expand All @@ -11,6 +11,18 @@ import path from 'path';
async function bootstrap(): Promise<void> {
const app: INestApplication = await NestFactory.create(AppModule);

// Enable global validation pipe
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: {
enableImplicitConversion: true,
},
}),
);

// Configure body parser limits for file uploads (base64 encoded files)
app.use(express.json({ limit: '15mb' }));
app.use(express.urlencoded({ limit: '15mb', extended: true }));
Expand Down
47 changes: 47 additions & 0 deletions apps/api/src/trust-portal/dto/domain-status.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, Matches } from 'class-validator';

export class GetDomainStatusDto {
@ApiProperty({
description: 'The domain name to check status for',
example: 'portal.example.com',
})
@IsString()
@IsNotEmpty({ message: 'domain cannot be empty' })
@Matches(/^(?!-)[A-Za-z0-9-]+([-\.]{1}[a-z0-9]+)*\.[A-Za-z]{2,6}$/, {
message: 'domain must be a valid domain format',
})
domain: string;
}

export class DomainVerificationDto {
@ApiProperty({ description: 'Verification type (e.g., TXT, CNAME)' })
type: string;

@ApiProperty({ description: 'Domain for verification' })
domain: string;

@ApiProperty({ description: 'Verification value' })
value: string;

@ApiProperty({
description: 'Reason for verification status',
required: false,
})
reason?: string;
}

export class DomainStatusResponseDto {
@ApiProperty({ description: 'The domain name' })
domain: string;

@ApiProperty({ description: 'Whether the domain is verified' })
verified: boolean;

@ApiProperty({
description: 'Verification records for the domain',
type: [DomainVerificationDto],
required: false,
})
verification?: DomainVerificationDto[];
}
68 changes: 68 additions & 0 deletions apps/api/src/trust-portal/trust-portal.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import {
Controller,
Get,
HttpCode,
HttpStatus,
Query,
UseGuards,
} from '@nestjs/common';
import {
ApiHeader,
ApiOperation,
ApiQuery,
ApiResponse,
ApiSecurity,
ApiTags,
} from '@nestjs/swagger';
import { HybridAuthGuard } from '../auth/hybrid-auth.guard';
import {
DomainStatusResponseDto,
GetDomainStatusDto,
} from './dto/domain-status.dto';
import { TrustPortalService } from './trust-portal.service';

@ApiTags('Trust Portal')
@Controller({ path: 'trust-portal', version: '1' })
@UseGuards(HybridAuthGuard)
@ApiSecurity('apikey')
@ApiHeader({
name: 'X-Organization-Id',
description:
'Organization ID (required for session auth, optional for API key auth)',
required: false,
})
export class TrustPortalController {
constructor(private readonly trustPortalService: TrustPortalService) {}

@Get('domain/status')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Get domain verification status',
description:
'Retrieve the verification status and DNS records for a custom domain configured in the Vercel trust portal project',
})
@ApiQuery({
name: 'domain',
description: 'The domain name to check status for',
example: 'portal.example.com',
required: true,
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Domain status retrieved successfully',
type: DomainStatusResponseDto,
})
@ApiResponse({
status: HttpStatus.INTERNAL_SERVER_ERROR,
description: 'Failed to retrieve domain status from Vercel',
})
@ApiResponse({
status: HttpStatus.UNAUTHORIZED,
description: 'Unauthorized - Invalid or missing authentication',
})
async getDomainStatus(
@Query() dto: GetDomainStatusDto,
): Promise<DomainStatusResponseDto> {
return this.trustPortalService.getDomainStatus(dto);
}
}
12 changes: 12 additions & 0 deletions apps/api/src/trust-portal/trust-portal.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { TrustPortalController } from './trust-portal.controller';
import { TrustPortalService } from './trust-portal.service';

@Module({
imports: [AuthModule],
controllers: [TrustPortalController],
providers: [TrustPortalService],
exports: [TrustPortalService],
})
export class TrustPortalModule {}
117 changes: 117 additions & 0 deletions apps/api/src/trust-portal/trust-portal.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import {
BadRequestException,
Injectable,
InternalServerErrorException,
Logger,
} from '@nestjs/common';
import axios, { AxiosInstance } from 'axios';
import {
DomainStatusResponseDto,
DomainVerificationDto,
GetDomainStatusDto,
} from './dto/domain-status.dto';

interface VercelDomainVerification {
type: string;
domain: string;
value: string;
reason?: string;
}

interface VercelDomainResponse {
name: string;
verified: boolean;
verification?: VercelDomainVerification[];
}

@Injectable()
export class TrustPortalService {
private readonly logger = new Logger(TrustPortalService.name);
private readonly vercelApi: AxiosInstance;

constructor() {
const bearerToken = process.env.VERCEL_ACCESS_TOKEN;

if (!bearerToken) {
this.logger.warn('VERCEL_ACCESS_TOKEN is not set');
}

// Initialize axios instance for Vercel API
this.vercelApi = axios.create({
baseURL: 'https://api.vercel.com',
headers: {
Authorization: `Bearer ${bearerToken || ''}`,
'Content-Type': 'application/json',
},
});
}

async getDomainStatus(
dto: GetDomainStatusDto,
): Promise<DomainStatusResponseDto> {
const { domain } = dto;

if (!process.env.TRUST_PORTAL_PROJECT_ID) {
throw new InternalServerErrorException(
'TRUST_PORTAL_PROJECT_ID is not configured',
);
}

if (!process.env.VERCEL_TEAM_ID) {
throw new InternalServerErrorException(
'VERCEL_TEAM_ID is not configured',
);
}

if (!domain) {
throw new BadRequestException('Domain is required');
}

try {
this.logger.log(`Fetching domain status for: ${domain}`);

// Get domain information including verification status
// Vercel API endpoint: GET /v9/projects/{projectId}/domains/{domain}
const response = await this.vercelApi.get<VercelDomainResponse>(
`/v9/projects/${process.env.TRUST_PORTAL_PROJECT_ID}/domains/${domain}`,
{
params: {
teamId: process.env.VERCEL_TEAM_ID,
},
},
);

const domainInfo = response.data;

const verification: DomainVerificationDto[] | undefined =
domainInfo.verification?.map((v) => ({
type: v.type,
domain: v.domain,
value: v.value,
reason: v.reason,
}));

return {
domain: domainInfo.name,
verified: domainInfo.verified ?? false,
verification,
};
} catch (error) {
this.logger.error(
`Failed to get domain status for ${domain}:`,
error instanceof Error ? error.stack : error,
);

// Handle axios errors with more detail
if (axios.isAxiosError(error)) {
const statusCode = error.response?.status;
const message = error.response?.data?.error?.message || error.message;
this.logger.error(`Vercel API error (${statusCode}): ${message}`);
}

throw new InternalServerErrorException(
'Failed to get domain status from Vercel',
);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use client';

import { useDomain } from '@/hooks/use-domain';
import { Button } from '@comp/ui/button';
import {
Card,
Expand All @@ -13,9 +14,9 @@ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '
import { Input } from '@comp/ui/input';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@comp/ui/tooltip';
import { zodResolver } from '@hookform/resolvers/zod';
import { AlertCircle, CheckCircle, ClipboardCopy, Loader2 } from 'lucide-react';
import { AlertCircle, CheckCircle, ClipboardCopy, ExternalLink, Loader2 } from 'lucide-react';
import { useAction } from 'next-safe-action/hooks';
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
Expand Down Expand Up @@ -51,6 +52,17 @@ export function TrustPortalDomain({
const [isTxtVerified, setIsTxtVerified] = useState(false);
const [isVercelTxtVerified, setIsVercelTxtVerified] = useState(false);

const { data: domainStatus } = useDomain(initialDomain);

const verificationInfo = useMemo(() => {
const data = domainStatus?.data;
if (data && !data.verified && data.verification && data.verification.length > 0) {
return data.verification[0];
}

return null;
}, [domainStatus]);

useEffect(() => {
const isCnameVerified = localStorage.getItem(`${initialDomain}-isCnameVerified`);
const isTxtVerified = localStorage.getItem(`${initialDomain}-isTxtVerified`);
Expand Down Expand Up @@ -215,6 +227,28 @@ export function TrustPortalDomain({
initialDomain !== '' &&
!domainVerified && (
<div className="space-y-2 pt-2">
{verificationInfo && (
<div className="rounded-md border border-amber-200 bg-amber-100 p-4 dark:border-amber-900 dark:bg-amber-950">
<div className="flex gap-3">
<AlertCircle className="h-5 w-5 shrink-0 text-amber-600 dark:text-amber-400" />
<p className="text-amber-100 text-sm dark:text-amber-200">
This domain is linked to another Vercel account. To use it with this
project, add a {verificationInfo.type} record at{' '}
{verificationInfo.domain} to verify ownership. You can remove the record
after verification is complete.
<a
href="https://vercel.com/docs/domains/troubleshooting#misconfigured-domain-issues"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-white underline dark:text-white ml-2"
>
Learn more
<ExternalLink className="ml-1 mb-0.5 inline-block h-4 w-4 font-bold text-white dark:text-white stroke-2" />
</a>
</p>
</div>
</div>
)}
<div className="rounded-md border">
<div className="text-sm">
<table className="hidden w-full lg:table">
Expand Down
25 changes: 25 additions & 0 deletions apps/app/src/hooks/use-domain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
'use client';

import { useApiSWR } from '@/hooks/use-api-swr';

export interface DomainVerification {
type: string;
domain: string;
value: string;
reason?: string;
}

export interface DomainStatusResponse {
domain: string;
verified: boolean;
verification: DomainVerification[];
}

export function useDomain(domain: string) {
const endpoint =
domain && domain.trim() !== ''
? `/v1/trust-portal/domain/status?domain=${domain}`
: null;

return useApiSWR<DomainStatusResponse>(endpoint);
}
Loading
Loading