Some checks failed
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 11s
Deploy / Build API Image (push) Failing after 18s
Deploy / Build AI Services Image (push) Failing after 9s
E2E Tests / Playwright E2E (push) Failing after 11s
Deploy / Smoke Test Staging (push) Has been skipped
CI / E2E Tests (push) Has been skipped
Deploy / Build Web Image (push) Failing after 8s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
crossOriginResourcePolicy: 'same-origin' blocks browser fetch from platform.goodgo.vn to api.goodgo.vn. Changed to 'cross-origin'. Also disabled crossOriginEmbedderPolicy which conflicts with CORS. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
144 lines
5.0 KiB
TypeScript
144 lines
5.0 KiB
TypeScript
import './instrument';
|
|
|
|
// BigInt cannot be serialized by JSON.stringify by default.
|
|
// Polyfill toJSON so Express/NestJS can serialize Prisma BigInt fields.
|
|
(BigInt.prototype as any).toJSON = function () {
|
|
return this.toString();
|
|
};
|
|
|
|
import { RequestMethod, ValidationPipe } from '@nestjs/common';
|
|
import { NestFactory } from '@nestjs/core';
|
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
|
import cookieParser from 'cookie-parser';
|
|
import helmet from 'helmet';
|
|
import { LoggerService, validateEnv } from '@modules/shared';
|
|
import { AppModule } from './app.module';
|
|
|
|
async function bootstrap() {
|
|
validateEnv();
|
|
const app = await NestFactory.create(AppModule, {
|
|
bufferLogs: true,
|
|
rawBody: true,
|
|
bodyParser: true,
|
|
});
|
|
const logger = app.get(LoggerService);
|
|
app.useLogger(logger);
|
|
|
|
// ── API Versioning — global /api/v1/ prefix ──
|
|
app.setGlobalPrefix('api/v1', {
|
|
exclude: [
|
|
{ path: 'health', method: RequestMethod.GET },
|
|
{ path: 'health/(.*)', method: RequestMethod.GET },
|
|
],
|
|
});
|
|
|
|
// ── OpenAPI / Swagger ──
|
|
const swaggerConfig = new DocumentBuilder()
|
|
.setTitle('Goodgo Platform API')
|
|
.setDescription('Real-estate platform API — listings, search, payments, subscriptions, analytics')
|
|
.setVersion('1.0')
|
|
.addBearerAuth(
|
|
{ type: 'http', scheme: 'bearer', bearerFormat: 'JWT' },
|
|
'JWT',
|
|
)
|
|
.addTag('auth', 'Authentication & user profile')
|
|
.addTag('listings', 'Property listings CRUD & moderation')
|
|
.addTag('search', 'Full-text & geo search')
|
|
.addTag('payments', 'Payment processing & callbacks')
|
|
.addTag('subscriptions', 'Plans, billing & usage metering')
|
|
.addTag('admin', 'Admin panel operations')
|
|
.addTag('notifications', 'Notification history & preferences')
|
|
.addTag('analytics', 'Market reports & price analytics')
|
|
.addTag('reviews', 'Property reviews & ratings')
|
|
.addTag('mcp', 'Model Context Protocol server transport')
|
|
.build();
|
|
const document = SwaggerModule.createDocument(app, swaggerConfig);
|
|
SwaggerModule.setup('api/v1/docs', app, document, {
|
|
swaggerOptions: { persistAuthorization: true },
|
|
jsonDocumentUrl: 'api/v1/docs-json',
|
|
});
|
|
|
|
// ── Security Headers (Helmet) ──
|
|
app.use(
|
|
helmet({
|
|
// CSP relaxed for API — responses are consumed cross-origin by the web frontend
|
|
contentSecurityPolicy: {
|
|
directives: {
|
|
defaultSrc: ["'self'"],
|
|
scriptSrc: ["'self'", "'unsafe-inline'", 'https://cdn.jsdelivr.net'],
|
|
styleSrc: ["'self'", "'unsafe-inline'", 'https://cdn.jsdelivr.net'],
|
|
imgSrc: ["'self'", 'data:', 'https:', 'blob:'],
|
|
connectSrc: ["'self'", 'https://cdn.jsdelivr.net', 'https://api.goodgo.vn'],
|
|
fontSrc: ["'self'", 'data:'],
|
|
objectSrc: ["'none'"],
|
|
frameSrc: ["'none'"],
|
|
baseUri: ["'self'"],
|
|
formAction: ["'self'"],
|
|
},
|
|
},
|
|
// Must allow cross-origin for API consumed by platform.goodgo.vn
|
|
crossOriginEmbedderPolicy: false,
|
|
crossOriginOpenerPolicy: false,
|
|
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
|
frameguard: { action: 'deny' },
|
|
hsts: { maxAge: 31536000, includeSubDomains: true, preload: true },
|
|
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
|
|
}),
|
|
);
|
|
|
|
// ── Permissions-Policy Header ──
|
|
app.use((_req: unknown, res: { setHeader: (name: string, value: string) => void }, next: () => void) => {
|
|
res.setHeader(
|
|
'Permissions-Policy',
|
|
'camera=(), microphone=(), geolocation=(self), payment=(self)',
|
|
);
|
|
next();
|
|
});
|
|
|
|
// ── Cookie Parser (required for CSRF double-submit pattern) ──
|
|
app.use(cookieParser());
|
|
|
|
// ── CORS ──
|
|
const corsOrigins = process.env['CORS_ORIGINS'];
|
|
if (!corsOrigins && process.env['NODE_ENV'] === 'production') {
|
|
throw new Error('CORS_ORIGINS must be set in production');
|
|
}
|
|
const allowedOrigins = (corsOrigins ?? 'http://localhost:3000')
|
|
.split(',')
|
|
.map((o) => o.trim());
|
|
app.enableCors({
|
|
origin: allowedOrigins,
|
|
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
|
allowedHeaders: ['Content-Type', 'Authorization', 'X-Correlation-Id', 'X-CSRF-Token'],
|
|
exposedHeaders: ['X-Correlation-Id'],
|
|
credentials: true,
|
|
maxAge: 86400,
|
|
});
|
|
|
|
// ── Global Validation Pipe ──
|
|
app.useGlobalPipes(
|
|
new ValidationPipe({
|
|
whitelist: true,
|
|
forbidNonWhitelisted: true,
|
|
transform: true,
|
|
transformOptions: { enableImplicitConversion: true },
|
|
disableErrorMessages: process.env['NODE_ENV'] === 'production',
|
|
}),
|
|
);
|
|
|
|
// ── Request Body Size Limit ──
|
|
const expressApp = app.getHttpAdapter().getInstance();
|
|
|
|
// ── Trust Proxy (for rate limiting behind reverse proxy) ──
|
|
expressApp.set('trust proxy', 1);
|
|
|
|
// ── Graceful Shutdown ──
|
|
app.enableShutdownHooks();
|
|
|
|
const port = process.env['PORT'] ?? 3001;
|
|
await app.listen(port);
|
|
logger.log(`API running on http://localhost:${port}/api/v1`, 'Bootstrap');
|
|
}
|
|
|
|
bootstrap();
|