lilith-platform.live/codebase/@features/my/backend-api/src/server.ts
2026-04-20 18:28:22 -07:00

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);
});
}