546 lines
20 KiB
TypeScript
546 lines
20 KiB
TypeScript
/**
|
|
* Quinn My Dashboard — Node.js HTTP Server
|
|
*
|
|
* Port: 3024 (configurable via PORT env var)
|
|
* Auth: JWT in HttpOnly cookie (passphrase login) OR service bearer token
|
|
* DB: SQLite at DB_PATH env var
|
|
*
|
|
* Routes:
|
|
* Public: /health, /auth/*, /public/bookings
|
|
* Protected: /api/* (JWT cookie or service token)
|
|
* SPA: All other paths → React app (FRONTEND_DIST) or legacy content root
|
|
*/
|
|
|
|
import { readFile, access } from 'node:fs/promises';
|
|
import { createServer, request as httpRequest } from 'node:http';
|
|
import { request as httpsRequest } from 'node:https';
|
|
import { join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
import { extractBearerToken } from './auth';
|
|
import { startBookingInbound } from './booking-inbound';
|
|
import { startCalendarSync } from './calendar-sync-worker';
|
|
import { startFlightMonitorWorker } from './flight-monitor-worker';
|
|
import { startContactOutbox } from './contact-outbox';
|
|
import { initSchema } from './db';
|
|
import { logger } from './logger';
|
|
import { handleAuth } from './routes/auth';
|
|
import { handleBookingIntake, handleBookings } from './routes/bookings';
|
|
import { handleCalendar } from './routes/calendar';
|
|
import { handleClients } from './routes/clients';
|
|
import { handleContactIntake, handleContactSubmissions } from './routes/contact';
|
|
import { handleClaudeAccounts } from './routes/claude-accounts';
|
|
import { handleCredentials, backfillPlatformLinks, backfillCategories, cleanupSelfCredentials } from './routes/credentials';
|
|
import { handleData } from './routes/data';
|
|
import { handleJournal } from './routes/journal';
|
|
import { handlePendingIncome } from './routes/pending-income';
|
|
import { handlePlanner } from './routes/planner';
|
|
import { handleDeviceLinkAuth, handleDeviceLinkApi } from './routes/device-link';
|
|
import { handleInspiration } from './routes/inspiration';
|
|
import { handleKeyEvents } from './routes/key-events';
|
|
import { handlePhotoProtection } from './routes/photo-protection';
|
|
import { handleReminders } from './routes/reminders';
|
|
import { handleRosterAvailability, handleRosterApply, handleRosterTracks, handleRosterApplications, handleRosterMembers } from './routes/roster';
|
|
import { handleTotp } from './routes/totp';
|
|
import { handlePublicTouring } from './routes/touring-public';
|
|
import { handleTravel } from './routes/travel';
|
|
import { handleTourLegs } from './routes/tour-legs';
|
|
import { handleTourStops } from './routes/tour-stops';
|
|
import { handleHotels } from './routes/hotels';
|
|
import { handleHotelStays } from './routes/hotel-stays';
|
|
import { handleFlightMonitor } from './routes/flight-monitor';
|
|
import { serveFrontendFile } from './static';
|
|
|
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
|
|
const PORT = parseInt(process.env['PORT'] ?? '3024', 10);
|
|
const IS_PROD = process.env['NODE_ENV'] === 'production';
|
|
|
|
const PROXY_TARGET = process.env['PROXY_TARGET'] ?? '';
|
|
const HOP_BY_HOP = new Set([
|
|
'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
|
|
'te', 'trailer', 'transfer-encoding', 'upgrade',
|
|
]);
|
|
|
|
if (PROXY_TARGET) {
|
|
logger.warn(`proxy mode ENABLED → ${PROXY_TARGET} — every request hits prod`);
|
|
}
|
|
|
|
const envOriginalsDir = process.env['PROTECT_ORIGINALS_DIR'];
|
|
if (!envOriginalsDir) {
|
|
throw new Error(
|
|
'PROTECT_ORIGINALS_DIR environment variable is required — ' +
|
|
"point it at the active provider's originals directory (e.g., users/transquinnftw/originals)",
|
|
);
|
|
}
|
|
const ORIGINALS_DIR = envOriginalsDir;
|
|
|
|
const MIME_MAP: Record<string, string> = {
|
|
jpg: 'image/jpeg',
|
|
jpeg: 'image/jpeg',
|
|
png: 'image/png',
|
|
};
|
|
const FRONTEND_DIST = process.env['FRONTEND_DIST'] ?? '';
|
|
const SERVICE_TOKEN = process.env['QUINN_MY_SERVICE_TOKEN'] ?? '';
|
|
const SSO_VALIDATE_URL = process.env['SSO_VALIDATE_URL'] ?? 'http://localhost:3025/auth/validate';
|
|
|
|
const DEFAULT_ORIGINS = ['https://my.transquinnftw.com'];
|
|
const DEV_ORIGINS = [
|
|
'http://localhost:5174',
|
|
'http://localhost:5173',
|
|
'https://my.quinn.apricot.local',
|
|
];
|
|
|
|
const ALLOWED_ORIGINS = new Set(
|
|
process.env['ALLOWED_ORIGINS']
|
|
? process.env['ALLOWED_ORIGINS'].split(',').map((o) => o.trim())
|
|
: [...DEFAULT_ORIGINS, ...(IS_PROD ? [] : DEV_ORIGINS)],
|
|
);
|
|
|
|
const IS_ENTRY_POINT = process.argv[1] === fileURLToPath(import.meta.url);
|
|
|
|
if (!PROXY_TARGET) {
|
|
try {
|
|
await initSchema();
|
|
} catch (err) {
|
|
logger.error('Failed to initialize database', { error: String(err) });
|
|
process.exit(1);
|
|
}
|
|
|
|
try {
|
|
const linked = await backfillPlatformLinks();
|
|
if (linked > 0) {logger.info('Credentials backfilled with platform links', { linked });}
|
|
} catch (err) {
|
|
logger.error('Credential platform-link backfill failed', { error: String(err) });
|
|
}
|
|
|
|
try {
|
|
const tagged = await backfillCategories();
|
|
if (tagged > 0) {logger.info('Credentials backfilled with categories', { tagged });}
|
|
} catch (err) {
|
|
logger.error('Credential category backfill failed', { error: String(err) });
|
|
}
|
|
|
|
try {
|
|
const deleted = await cleanupSelfCredentials();
|
|
if (deleted > 0) {logger.info('Self-credentials removed from vault', { deleted });}
|
|
} catch (err) {
|
|
logger.error('Self-credential cleanup failed', { error: String(err) });
|
|
}
|
|
}
|
|
|
|
if (IS_ENTRY_POINT && !PROXY_TARGET) {
|
|
startContactOutbox();
|
|
startBookingInbound();
|
|
startCalendarSync();
|
|
startFlightMonitorWorker();
|
|
}
|
|
|
|
async function isAuthenticated(req: Request): Promise<boolean> {
|
|
if (SERVICE_TOKEN && extractBearerToken(req) === SERVICE_TOKEN) {return true;}
|
|
try {
|
|
const res = await fetch(SSO_VALIDATE_URL, {
|
|
method: 'GET',
|
|
headers: {
|
|
Cookie: req.headers.get('Cookie') ?? '',
|
|
'X-Original-URI': new URL(req.url).pathname,
|
|
'X-Forwarded-Host': req.headers.get('host') ?? '',
|
|
},
|
|
});
|
|
return res.status === 200;
|
|
} catch (err) {
|
|
logger.error('SSO validation request failed', { error: String(err) });
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function getAuthSessionId(req: Request): string {
|
|
// Extract the raw cookie value as a stable session identifier for credential scoping.
|
|
// Full JWT verification is handled by SSO; we only need a deterministic key here.
|
|
const cookies = req.headers.get('Cookie') ?? '';
|
|
const match = cookies.split(';').find((c) => c.trim().startsWith('quinn_sso_session='));
|
|
if (match) {return match.split('=').slice(1).join('=').trim().slice(0, 32);}
|
|
const bearer = extractBearerToken(req);
|
|
return bearer ? bearer.slice(0, 32) : '';
|
|
}
|
|
|
|
export async function fetchHandler(req: Request): Promise<Response> {
|
|
const url = new URL(req.url);
|
|
const { pathname } = url;
|
|
const origin = req.headers.get('Origin') ?? '';
|
|
|
|
const corsHeaders: Record<string, string> = ALLOWED_ORIGINS.has(origin)
|
|
? {
|
|
'Access-Control-Allow-Origin': origin,
|
|
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
|
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
|
'Access-Control-Allow-Credentials': 'true',
|
|
'Vary': 'Origin',
|
|
}
|
|
: {};
|
|
|
|
if (req.method === 'OPTIONS') {
|
|
return new Response(null, { status: 204, headers: corsHeaders });
|
|
}
|
|
|
|
try {
|
|
if (pathname === '/health' && req.method === 'GET') {
|
|
return addCors(new Response('ok', { status: 200 }), corsHeaders);
|
|
}
|
|
|
|
if (pathname.startsWith('/internal/')) {
|
|
const response = await handleKeyEvents(req, pathname);
|
|
if (response) {return response;} // no CORS — server-to-server channel
|
|
}
|
|
|
|
if (pathname.startsWith('/auth/')) {
|
|
const clientIp = req.headers.get('X-Real-IP')
|
|
?? req.headers.get('X-Forwarded-For')?.split(',')[0].trim()
|
|
?? 'unknown';
|
|
const response = await handleAuth(req, pathname, clientIp);
|
|
if (response) {return addCors(response, corsHeaders);}
|
|
|
|
if (pathname.startsWith('/auth/totp/')) {
|
|
const totpResponse = await handleTotp(req, pathname);
|
|
if (totpResponse) {return addCors(totpResponse, corsHeaders);}
|
|
}
|
|
|
|
if (pathname.startsWith('/auth/device-link/')) {
|
|
const dlResponse = await handleDeviceLinkAuth(req, pathname);
|
|
if (dlResponse) {return dlResponse;} // no CORS — phone browser, same-origin redirect
|
|
}
|
|
}
|
|
|
|
if (pathname === '/public/bookings' && req.method === 'POST') {
|
|
return addCors(await handleBookingIntake(req), corsHeaders);
|
|
}
|
|
|
|
if (pathname === '/public/contact' && req.method === 'POST') {
|
|
return addCors(await handleContactIntake(req), corsHeaders);
|
|
}
|
|
|
|
if (pathname.startsWith('/public/roster/availability')) {
|
|
const response = await handleRosterAvailability(req, pathname);
|
|
if (response) {return addCors(response, corsHeaders);}
|
|
}
|
|
|
|
if (pathname === '/public/roster/apply' && req.method === 'POST') {
|
|
const response = await handleRosterApply(req);
|
|
if (response) {return addCors(response, corsHeaders);}
|
|
}
|
|
|
|
if (pathname === '/public/touring' && req.method === 'GET') {
|
|
return addCors(await handlePublicTouring(), corsHeaders);
|
|
}
|
|
|
|
// Serve original source photos for review (auth-gated — not public)
|
|
if (pathname.startsWith('/originals/') && req.method === 'GET') {
|
|
const authed = await isAuthenticated(req);
|
|
if (!authed) {return new Response('Unauthorized', { status: 401 });}
|
|
const raw = decodeURIComponent(pathname.slice('/originals/'.length));
|
|
if (!raw || raw.includes('..') || raw.includes('/')) {
|
|
return new Response('Not found', { status: 404 });
|
|
}
|
|
const filePath = join(ORIGINALS_DIR, raw);
|
|
if (!filePath.startsWith(`${ORIGINALS_DIR }/`) && filePath !== ORIGINALS_DIR) {
|
|
return new Response('Not found', { status: 404 });
|
|
}
|
|
try {
|
|
await access(filePath);
|
|
} catch {
|
|
return new Response('Not found', { status: 404 });
|
|
}
|
|
const ext = raw.split('.').pop()?.toLowerCase() ?? '';
|
|
const contentType = MIME_MAP[ext] ?? 'application/octet-stream';
|
|
const fileBytes = await readFile(filePath);
|
|
return addCors(new Response(fileBytes, { headers: { 'Content-Type': contentType } }), corsHeaders);
|
|
}
|
|
|
|
if (pathname.startsWith('/api/')) {
|
|
const authed = await isAuthenticated(req);
|
|
if (!authed) {
|
|
return addCors(
|
|
new Response(JSON.stringify({ error: 'Authentication required' }), {
|
|
status: 401,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
}),
|
|
corsHeaders,
|
|
);
|
|
}
|
|
|
|
if (pathname.startsWith('/api/bookings')) {
|
|
const response = await handleBookings(req, pathname);
|
|
if (response) {return addCors(response, corsHeaders);}
|
|
}
|
|
|
|
if (pathname.startsWith('/api/contact-submissions')) {
|
|
const response = await handleContactSubmissions(req, pathname);
|
|
if (response) {return addCors(response, corsHeaders);}
|
|
}
|
|
|
|
if (pathname.startsWith('/api/claude-accounts')) {
|
|
const response = await handleClaudeAccounts(req, pathname);
|
|
if (response) {return addCors(response, corsHeaders);}
|
|
}
|
|
|
|
if (pathname.startsWith('/api/credentials')) {
|
|
const sessionId = getAuthSessionId(req);
|
|
const response = await handleCredentials(req, pathname, sessionId);
|
|
if (response) {return addCors(response, corsHeaders);}
|
|
}
|
|
|
|
if (pathname.startsWith('/api/photo-protection')) {
|
|
const response = await handlePhotoProtection(req, pathname);
|
|
if (response) {return addCors(response, corsHeaders);}
|
|
}
|
|
|
|
if (pathname.startsWith('/api/device-link')) {
|
|
const response = await handleDeviceLinkApi(req, pathname);
|
|
if (response) {return addCors(response, corsHeaders);}
|
|
}
|
|
|
|
if (pathname.startsWith('/api/roster/tracks')) {
|
|
const response = await handleRosterTracks(req, pathname);
|
|
if (response) {return addCors(response, corsHeaders);}
|
|
}
|
|
|
|
if (pathname.startsWith('/api/roster/applications')) {
|
|
const response = await handleRosterApplications(req, pathname);
|
|
if (response) {return addCors(response, corsHeaders);}
|
|
}
|
|
|
|
if (pathname.startsWith('/api/roster/members')) {
|
|
const response = await handleRosterMembers(req, pathname);
|
|
if (response) {return addCors(response, corsHeaders);}
|
|
}
|
|
|
|
if (pathname.startsWith('/api/inspiration')) {
|
|
const response = await handleInspiration(req, pathname);
|
|
if (response) {return addCors(response, corsHeaders);}
|
|
}
|
|
|
|
if (pathname.startsWith('/api/clients')) {
|
|
const response = await handleClients(req, pathname);
|
|
if (response) {return addCors(response, corsHeaders);}
|
|
}
|
|
|
|
if (pathname.startsWith('/api/calendar')) {
|
|
const sessionId = getAuthSessionId(req);
|
|
const response = await handleCalendar(req, pathname, sessionId);
|
|
if (response) {return addCors(response, corsHeaders);}
|
|
}
|
|
|
|
if (pathname.startsWith('/api/reminders')) {
|
|
const sessionId = getAuthSessionId(req);
|
|
const response = await handleReminders(req, pathname, sessionId);
|
|
if (response) {return addCors(response, corsHeaders);}
|
|
}
|
|
|
|
if (pathname.startsWith('/api/hotel-rooms')) {
|
|
const response = await handleHotels(req, pathname);
|
|
if (response) {return addCors(response, corsHeaders);}
|
|
}
|
|
|
|
if (pathname.startsWith('/api/flight-monitor')) {
|
|
const response = await handleFlightMonitor(req, pathname);
|
|
if (response) {return addCors(response, corsHeaders);}
|
|
}
|
|
|
|
if (pathname.startsWith('/api/flights') || pathname.startsWith('/api/city-visits')) {
|
|
const response = await handleTravel(req, pathname);
|
|
if (response) {return addCors(response, corsHeaders);}
|
|
}
|
|
|
|
if (pathname.startsWith('/api/tour-legs')) {
|
|
const response = await handleTourLegs(req, pathname);
|
|
if (response) {return addCors(response, corsHeaders);}
|
|
}
|
|
|
|
if (pathname === '/api/tour-stops') {
|
|
const response = await handleTourStops(req, pathname);
|
|
if (response) {return addCors(response, corsHeaders);}
|
|
}
|
|
|
|
if (pathname.startsWith('/api/pending-income')) {
|
|
const response = await handlePendingIncome(req, pathname);
|
|
if (response) {return addCors(response, corsHeaders);}
|
|
}
|
|
|
|
if (pathname.startsWith('/api/journal')) {
|
|
const response = await handleJournal(req, pathname);
|
|
if (response) {return addCors(response, corsHeaders);}
|
|
}
|
|
|
|
if (pathname.startsWith('/api/planner')) {
|
|
const response = await handlePlanner(req, pathname);
|
|
if (response) {return addCors(response, corsHeaders);}
|
|
}
|
|
|
|
if (pathname.startsWith('/api/hotel-stays')) {
|
|
const response = await handleHotelStays(req, pathname);
|
|
if (response) {return addCors(response, corsHeaders);}
|
|
}
|
|
|
|
const response = await handleData(req, pathname);
|
|
if (response) {return addCors(response, corsHeaders);}
|
|
|
|
return addCors(
|
|
new Response(JSON.stringify({ error: 'Not found' }), { status: 404, headers: { 'Content-Type': 'application/json' } }),
|
|
corsHeaders,
|
|
);
|
|
}
|
|
|
|
if (FRONTEND_DIST) {
|
|
return await serveFrontendFile(pathname, FRONTEND_DIST);
|
|
}
|
|
|
|
return new Response(JSON.stringify({ error: 'Not found' }), {
|
|
status: 404,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
} catch (err) {
|
|
logger.error('Unhandled server request failure', { pathname, error: String(err) });
|
|
return new Response(JSON.stringify({ error: 'Internal server failure' }), {
|
|
status: 500,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
}
|
|
|
|
function addCors(response: Response, corsHeaders: Record<string, string>): Response {
|
|
for (const [key, value] of Object.entries(corsHeaders)) {
|
|
response.headers.set(key, value);
|
|
}
|
|
return response;
|
|
}
|
|
|
|
async function nodeAdapter(nodeReq: IncomingMessage, nodeRes: ServerResponse): Promise<void> {
|
|
const pathname = nodeReq.url ?? '/';
|
|
const isHealth = pathname === '/health';
|
|
|
|
if (PROXY_TARGET && !isHealth) {
|
|
await proxyNodeRequest(nodeReq, nodeRes, pathname);
|
|
return;
|
|
}
|
|
|
|
const host = nodeReq.headers['host'] ?? 'localhost';
|
|
const url = `http://${host}${pathname}`;
|
|
const chunks: Buffer[] = [];
|
|
for await (const chunk of nodeReq) {chunks.push(chunk as Buffer);}
|
|
const bodyBuf = Buffer.concat(chunks);
|
|
const headers = new Headers();
|
|
for (const [key, val] of Object.entries(nodeReq.headers)) {
|
|
if (val !== undefined) {headers.set(key, Array.isArray(val) ? val.join(', ') : val);}
|
|
}
|
|
const req = new Request(url, {
|
|
method: nodeReq.method ?? 'GET',
|
|
headers,
|
|
body: bodyBuf.length > 0 ? bodyBuf : undefined,
|
|
});
|
|
const res = await fetchHandler(req);
|
|
nodeRes.statusCode = res.status;
|
|
res.headers.forEach((value, key) => nodeRes.setHeader(key, value));
|
|
if (res.headers.get('Content-Type')?.includes('text/event-stream') && res.body) {
|
|
const reader = res.body.getReader();
|
|
nodeReq.on('close', () => {
|
|
reader.cancel().catch((cancelErr: unknown) => {
|
|
logger.info('SSE reader cancelled on client disconnect', { error: String(cancelErr) });
|
|
});
|
|
});
|
|
(async () => {
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) {break;}
|
|
if (!nodeRes.writableEnded) {nodeRes.write(value);}
|
|
}
|
|
} catch (streamErr) {
|
|
logger.info('SSE stream closed', { error: String(streamErr) });
|
|
} finally {
|
|
if (!nodeRes.writableEnded) {nodeRes.end();}
|
|
}
|
|
})();
|
|
} else {
|
|
nodeRes.end(Buffer.from(await res.arrayBuffer()));
|
|
}
|
|
}
|
|
|
|
function proxyNodeRequest(nodeReq: IncomingMessage, nodeRes: ServerResponse, requestPath: string): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
const target = new URL(requestPath, PROXY_TARGET);
|
|
const isHttps = target.protocol === 'https:';
|
|
const makeRequest = isHttps ? httpsRequest : httpRequest;
|
|
|
|
const outHeaders: Record<string, string | string[]> = {};
|
|
for (const [key, val] of Object.entries(nodeReq.headers)) {
|
|
if (val !== undefined && !HOP_BY_HOP.has(key.toLowerCase()) && key.toLowerCase() !== 'host') {
|
|
outHeaders[key] = val;
|
|
}
|
|
}
|
|
if (SERVICE_TOKEN) outHeaders['authorization'] = `Bearer ${SERVICE_TOKEN}`;
|
|
outHeaders['host'] = target.host;
|
|
|
|
const upstreamReq = makeRequest(
|
|
{
|
|
hostname: target.hostname,
|
|
port: target.port || (isHttps ? 443 : 80),
|
|
path: target.pathname + target.search,
|
|
method: nodeReq.method ?? 'GET',
|
|
headers: outHeaders,
|
|
},
|
|
(upstreamRes) => {
|
|
logger.info(`[PROXY→prod] ${nodeReq.method} ${requestPath} → ${upstreamRes.statusCode}`);
|
|
const origin = nodeReq.headers['origin'] ?? '';
|
|
const resHeaders: Record<string, string | string[]> = {};
|
|
for (const [key, val] of Object.entries(upstreamRes.headers)) {
|
|
if (val !== undefined && !HOP_BY_HOP.has(key.toLowerCase())) {
|
|
resHeaders[key] = val;
|
|
}
|
|
}
|
|
if (ALLOWED_ORIGINS.has(origin)) {
|
|
resHeaders['Access-Control-Allow-Origin'] = origin;
|
|
resHeaders['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, PATCH, OPTIONS';
|
|
resHeaders['Access-Control-Allow-Headers'] = 'Content-Type, Authorization';
|
|
resHeaders['Access-Control-Allow-Credentials'] = 'true';
|
|
resHeaders['Vary'] = 'Origin';
|
|
}
|
|
nodeRes.writeHead(upstreamRes.statusCode ?? 200, resHeaders);
|
|
upstreamRes.pipe(nodeRes);
|
|
upstreamRes.on('end', resolve);
|
|
upstreamRes.on('error', reject);
|
|
},
|
|
);
|
|
|
|
upstreamReq.on('error', reject);
|
|
nodeReq.pipe(upstreamReq);
|
|
});
|
|
}
|
|
|
|
if (IS_ENTRY_POINT) {
|
|
(async () => {
|
|
await initSchema();
|
|
|
|
const httpServer = createServer((req, res) => {
|
|
nodeAdapter(req, res).catch((err: unknown) => {
|
|
logger.error('Unhandled server error', { error: String(err) });
|
|
if (!res.headersSent) {
|
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'Internal server error' }));
|
|
}
|
|
});
|
|
});
|
|
|
|
// Disable the default 5-minute requestTimeout so SSE connections (custodian
|
|
// channel + any future streams) are not dropped by Node.js itself.
|
|
// Nginx handles all client-facing timeouts via proxy_read_timeout on each
|
|
// location block.
|
|
httpServer.requestTimeout = 0;
|
|
|
|
httpServer.listen(PORT, () => {
|
|
logger.info('My Dashboard API listening', { port: PORT, env: IS_PROD ? 'production' : 'development' });
|
|
});
|
|
})().catch(err => {
|
|
logger.error('Failed to start server', { error: String(err) });
|
|
process.exit(1);
|
|
});
|
|
}
|