import { existsSync, unlinkSync } from 'fs'; import path from 'path'; import express from 'express'; import helmet from 'helmet'; import cors from 'cors'; import compression from 'compression'; import { env } from './config/env'; import { logger } from './utils/logger'; import { prisma } from './config/database'; import { redis } from './config/redis'; import { register, httpRequestDuration, httpRequestsTotal } from './utils/metrics'; import { errorHandler } from './middleware/error-handler'; import { authenticate } from './middleware/auth.middleware'; import { requireRole } from './middleware/rbac.middleware'; import { globalRateLimit, healthMetricsRateLimit } from './middleware/rate-limit'; import { authRouter } from './modules/auth/auth.routes'; import { giteaSsoRouter } from './modules/auth/gitea-sso.routes'; import { usersRouter } from './modules/users/users.routes'; import { provisioningRouter } from './modules/users/provisioning.routes'; import { campaignsRouter } from './modules/influence/campaigns/campaigns.routes'; import { campaignEmailsPublicRouter, campaignEmailsAdminRouter } from './modules/influence/campaign-emails/campaign-emails.routes'; import { emailQueueRouter } from './modules/influence/email-queue/email-queue.routes'; import { representativesRouter } from './modules/influence/representatives/representatives.routes'; import { campaignPublicRouter } from './modules/influence/campaigns/campaigns-public.routes'; import { campaignUserRouter } from './modules/influence/campaigns/campaigns-user.routes'; import { campaignModerationRouter } from './modules/influence/campaigns/campaigns-moderation.routes'; import { responseCampaignPublicRouter, responsesPublicRouter, responsesAdminRouter } from './modules/influence/responses/responses.routes'; import { locationsAdminRouter, locationsPublicRouter } from './modules/map/locations/locations.routes'; import { bulkGeocodeRouter } from './modules/map/locations/bulk-geocode.routes'; import { cutsAdminRouter, cutsPublicRouter } from './modules/map/cuts/cuts.routes'; import { shiftsAdminRouter, shiftsPublicRouter, shiftsVolunteerRouter } from './modules/map/shifts/shifts.routes'; import shiftSeriesRouter from './modules/map/shifts/shift-series.routes'; import { mapSettingsRouter } from './modules/map/settings/settings.routes'; import { qrRouter } from './modules/qr/qr.routes'; import { listmonkRouter } from './modules/listmonk/listmonk.routes'; import { listmonkWebhookRouter } from './modules/listmonk/listmonk-webhook.routes'; import { meetingPlannerAdminRouter, meetingPlannerPublicRouter } from './modules/meeting-planner/meeting-planner.routes'; import { strawPollAdminRouter } from './modules/polls/polls.routes'; import { strawPollPublicRouter } from './modules/polls/polls-public.routes'; import { strawPollWidgetRouter } from './modules/polls/polls-widget.routes'; import { pagesPublicRouter } from './modules/pages/pages-public.routes'; import { pagesAdminRouter } from './modules/pages/pages-admin.routes'; import { blocksRouter } from './modules/pages/blocks.routes'; import { docsRouter } from './modules/docs/docs.routes'; import { docsAccessRouter } from './modules/docs/docs-access.routes'; import { giteaSetupRouter } from './modules/gitea-setup/gitea-setup.routes'; import { giteaSetupService } from './modules/gitea-setup/gitea-setup.service'; import { servicesRouter } from './modules/services/services.routes'; import { siteSettingsRouter } from './modules/settings/settings.routes'; import { canvassVolunteerRouter, canvassAdminRouter } from './modules/map/canvass/canvass.routes'; import { canvassExportRouter } from './modules/map/canvass/canvass-export.routes'; import { trackingVolunteerRouter, trackingAdminRouter } from './modules/map/tracking/tracking.routes'; import { geocodingRouter } from './modules/map/geocoding/geocoding.routes'; import { eventsPublicRouter } from './modules/map/events/events.routes'; import { pangolinRouter } from './modules/pangolin/pangolin.routes'; import ccpRegistrationRouter from './modules/ccp-registration/ccp-registration.routes'; import { rocketchatRouter } from './modules/rocketchat/rocketchat.routes'; import { jitsiRouter } from './modules/jitsi/jitsi.routes'; import { rocketchatWebhookService } from './services/rocketchat-webhook.service'; import { gancioClient } from './services/gancio.client'; import { gancioSettingsSyncService } from './services/gancio-settings-sync.service'; import { narImportRouter } from './modules/map/locations/nar-import.routes'; import { areaImportRouter } from './modules/map/locations/area-import.routes'; import emailTemplatesRouter from './modules/email-templates/email-templates-admin.routes'; import { observabilityRouter } from './modules/observability/observability.routes'; import { upgradeRouter } from './modules/upgrade/upgrade.routes'; import { registryRouter } from './modules/registry/registry.routes'; import { dashboardRouter } from './modules/dashboard/dashboard.routes'; import { initEncryption } from './utils/crypto'; import { emailService } from './services/email.service'; import { emailQueueService } from './services/email-queue.service'; import { notificationQueueService } from './services/notification-queue.service'; import { geocodeQueueService } from './services/geocode-queue.service'; import { startProxy, stopProxy } from './services/listmonk-proxy.service'; import { pagesService } from './modules/pages/pages.service'; import { canvassService } from './modules/map/canvass/canvass.service'; import { trackingService } from './modules/map/tracking/tracking.service'; import { verificationTokenService } from './services/verification-token.service'; import { passwordResetTokenService } from './services/password-reset-token.service'; import { paymentsPublicRouter } from './modules/payments/payments-public.routes'; import { paymentsAdminRouter } from './modules/payments/payments-admin.routes'; import { donationPagesPublicRouter } from './modules/payments/donation-pages-public.routes'; import { donationPagesAdminRouter } from './modules/payments/donation-pages-admin.routes'; import { webhookService } from './modules/payments/webhook.service'; import { galleryAdsPublicRouter } from './modules/gallery-ads/gallery-ads-public.routes'; import { galleryAdsAdminRouter } from './modules/gallery-ads/gallery-ads-admin.routes'; import { petitionsPublicRouter, petitionVerifyRouter } from './modules/influence/petitions/petitions-public.routes'; import { petitionsAdminRouter } from './modules/influence/petitions/petitions.routes'; import { effectivenessRouter } from './modules/influence/effectiveness/effectiveness.routes'; import { actionCampaignsRouter, actionCampaignsAdminRouter } from './modules/action-campaigns/action-campaigns.routes'; import { volunteerDashboardRouter } from './modules/volunteer-dashboard/volunteer-dashboard.routes'; import { docsAnalyticsPublicRouter, docsAnalyticsAdminRouter } from './modules/docs-analytics/docs-analytics.routes'; import { analyticsUserRouter } from './modules/analytics/analytics-user.routes'; import { analyticsAdminRouter } from './modules/analytics/analytics.routes'; import { docsCommentsPublicRouter, docsCommentsAdminRouter } from './modules/docs-comments/docs-comments.routes'; import { volunteerInviteRouter } from './modules/volunteer-invite/volunteer-invite.routes'; import { docsAnalyticsService } from './modules/docs-analytics/docs-analytics.service'; import { smsContactsRouter } from './modules/sms/contacts/sms-contacts.routes'; import { smsCampaignsRouter } from './modules/sms/campaigns/sms-campaigns.routes'; import { smsConversationsRouter } from './modules/sms/conversations/sms-conversations.routes'; import { smsMessagesRouter } from './modules/sms/messages/sms-messages.routes'; import { smsDeviceRouter } from './modules/sms/device/sms-device.routes'; import { smsSetupRouter } from './modules/sms/setup/sms-setup.routes'; import { smsTemplatesRouter } from './modules/sms/templates/sms-templates.routes'; import { smsQueueService } from './services/sms-queue.service'; import { smsResponseSyncService } from './services/sms-response-sync.service'; import { smsDeviceMonitorService } from './services/sms-device-monitor.service'; import { reengagementService } from './services/reengagement.service'; import { socialDigestService } from './services/social-digest.service'; import { termuxClient } from './services/termux.client'; import { registerProvisioners } from './services/user-provisioning'; import { peopleRouter } from './modules/people/people.routes'; import { participantNeedsRouter } from './modules/people/participant-needs.routes'; import { profilePublicRouter } from './modules/people/profile-public.routes'; import { searchRouter } from './modules/search/search.routes'; import { activityPublicRouter } from './modules/activity/activity-public.routes'; import { newsletterPublicRouter } from './modules/newsletter/newsletter-public.routes'; import { eventsListPublicRouter } from './modules/events/events-public.routes'; import { homepageRouter } from './modules/homepage/homepage.routes'; import { ogRouter } from './modules/og/og.routes'; import { socialRouter } from './modules/social/social.routes'; import { errorReportRouter } from './modules/reports/error-report.routes'; import calendarRoutes from './modules/calendar/calendar.routes'; import feedRoutes from './modules/calendar/feed.routes'; import sharedCalendarRoutes from './modules/calendar/shared-calendar.routes'; import { adminCalendarRouter } from './modules/calendar/admin-calendar.routes'; import { ticketedEventsPublicRouter } from './modules/ticketed-events/ticketed-events-public.routes'; import { ticketedEventsAdminRouter } from './modules/ticketed-events/ticketed-events-admin.routes'; import { checkinRouter } from './modules/ticketed-events/checkin.routes'; import { sseService } from './modules/social/sse.service'; import { presenceService } from './modules/social/presence.service'; import { upgradeService } from './modules/upgrade/upgrade.service'; import { autoUpgradeService } from './services/auto-upgrade.service'; import { calendarFeedQueueService } from './services/calendar-feed-queue.service'; import { scheduledJobsQueueService } from './services/scheduled-jobs-queue.service'; import { pollAutoFinalizeQueueService } from './services/poll-auto-finalize-queue.service'; import { pollAutoCloseQueueService } from './services/poll-auto-close-queue.service'; import { pollSseService } from './modules/polls/polls-sse.service'; import { agendaRouter } from './modules/meetings/agenda.routes'; import { actionItemsRouter } from './modules/meetings/action-items.routes'; import { WebSocketServer } from 'ws'; import { docsCollabService } from './modules/docs/docs-collab.service'; import { correlationId } from './middleware/correlation-id'; import cookieParser from 'cookie-parser'; import { registerAllEventListeners } from './services/event-listeners'; import { eventBus } from './services/event-bus.service'; const app = express(); // Trust proxy chain: Pangolin/Newt → Nginx → API (2 hops in production) app.set('trust proxy', 2); // --- Middleware Stack --- app.use(correlationId); app.use(cookieParser()); app.use(helmet({ contentSecurityPolicy: env.CSP_ENABLED === 'true' ? { directives: { defaultSrc: ["'self'"], scriptSrc: ["'self'", "'unsafe-inline'"], styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'], fontSrc: ["'self'", 'https://fonts.gstatic.com', 'data:'], imgSrc: ["'self'", 'data:', 'blob:', 'https:'], connectSrc: ["'self'", 'wss:', 'ws:'], frameSrc: ["'self'", `*.${env.DOMAIN}`], frameAncestors: ["'self'", `*.${env.DOMAIN}`], objectSrc: ["'none'"], baseUri: ["'self'"], formAction: ["'self'"], }, } : false, })); app.use(cors({ origin: env.CORS_ORIGINS.split(',').map(s => s.trim()), credentials: true, })); app.use(compression()); // Stripe webhook — must receive raw body BEFORE express.json() parses it app.post('/api/payments/webhook', express.raw({ type: 'application/json' }), async (req, res) => { const signature = req.headers['stripe-signature']; if (!signature || typeof signature !== 'string') { res.status(400).json({ error: 'Missing stripe-signature header' }); return; } try { const event = await webhookService.constructEvent(req.body as Buffer, signature); await webhookService.handleEvent(event); res.json({ received: true }); } catch (err: unknown) { const message = err instanceof Error ? err.message : 'Webhook error'; res.status(400).json({ error: message }); } }); app.use(express.json({ limit: '10mb' })); app.use(express.urlencoded({ extended: true })); app.use(globalRateLimit); // Request metrics app.use((req, res, next) => { const end = httpRequestDuration.startTimer(); res.on('finish', () => { const route = req.route?.path || req.path; const labels = { method: req.method, route, status_code: res.statusCode.toString() }; end(labels); httpRequestsTotal.inc(labels); }); next(); }); // --- Health Check --- // Public (unauthenticated) for Docker healthcheck compatibility, but the // `?detailed=true` mode — which exposed disk space and internal service status // to any unauthenticated caller — was moved to the authenticated `/api/metrics` // consumers on 2026-04-12. The public path now returns only pass/fail for core // DB + Redis. app.get('/api/health', healthMetricsRateLimit, async (_req, res) => { const checks: Record = {}; try { await prisma.$queryRaw`SELECT 1`; checks.database = 'ok'; } catch { checks.database = 'error'; } try { await redis.ping(); checks.redis = 'ok'; } catch { checks.redis = 'error'; } const coreHealthy = checks.database === 'ok' && checks.redis === 'ok'; res.status(coreHealthy ? 200 : 503).json({ status: coreHealthy ? 'healthy' : 'degraded', version: process.env.npm_package_version || 'unknown', checks, }); }); // --- Metrics Endpoint (authenticated - SUPER_ADMIN only) --- app.get('/api/metrics', authenticate, requireRole('SUPER_ADMIN'), healthMetricsRateLimit, async (_req, res) => { res.set('Content-Type', register.contentType); res.end(await register.metrics()); }); // --- Internal Metrics Endpoint (for Prometheus scraping within Docker network) --- // Only accessible from Docker-internal network (nginx doesn't proxy this path externally) app.get('/api/metrics/internal', async (req, res) => { // Basic network-level protection: only allow from Docker bridge / localhost const remoteIp = req.ip || req.socket.remoteAddress || ''; const isInternal = remoteIp === '127.0.0.1' || remoteIp === '::1' || remoteIp.startsWith('172.') || remoteIp.startsWith('10.') || remoteIp.startsWith('192.168.'); if (!isInternal) { res.status(403).json({ error: 'Internal endpoint only' }); return; } res.set('Content-Type', register.contentType); res.end(await register.metrics()); }); // --- API Routes --- app.use('/api/auth', authRouter); app.use('/api/auth', giteaSsoRouter); // Gitea SSO validation (nginx auth_request) app.use('/api/users', usersRouter); app.use('/api/users', provisioningRouter); // User provisioning management (ADMIN roles) app.use('/api/campaigns', campaignPublicRouter); // Public campaign details (no auth) app.use('/api/campaigns', responseCampaignPublicRouter); // Public response routes (no auth) app.use('/api/campaigns', campaignEmailsPublicRouter); // Public email routes (no auth) app.use('/api/campaigns', campaignUserRouter); // User campaign submission (auth, non-temp) app.use('/api/campaigns', campaignModerationRouter); // Moderation queue (admin auth) app.use('/api/campaigns', campaignEmailsAdminRouter); // Admin email routes (auth required) app.use('/api/campaigns', campaignsRouter); // Admin campaign CRUD (auth required) app.use('/api/responses', responsesPublicRouter); // Public response actions (no auth) app.use('/api/responses', responsesAdminRouter); // Admin response moderation (auth required) app.use('/api/email-queue', emailQueueRouter); app.use('/api/representatives', representativesRouter); app.use('/api/map/locations', locationsPublicRouter); // Public map data (no auth) app.use('/api/map/locations', locationsAdminRouter); // Admin location CRUD (auth required) app.use('/api/map/locations/bulk-geocode', bulkGeocodeRouter); // Bulk re-geocoding (admin auth required) app.use('/api/map/nar-import', narImportRouter); // NAR server-side import (MAP_ADMIN+) app.use('/api/map/area-import', areaImportRouter); // Area import from multiple sources (MAP_ADMIN+) app.use('/api/map/cuts', cutsPublicRouter); // Public cut polygons (no auth) app.use('/api/map/cuts', cutsAdminRouter); // Admin cut CRUD (auth required) app.use('/api/map/shifts', shiftsPublicRouter); // Public shift listing + signup (no auth) app.use('/api/map/shifts', shiftsVolunteerRouter); // Volunteer shift signup (auth required) app.use('/api/map/shifts/series', shiftSeriesRouter); // Shift series management (MAP_ADMIN+) app.use('/api/map/shifts', shiftsAdminRouter); // Admin shift CRUD (auth required) app.use('/api/map/geocoding', geocodingRouter); // Geocoding search (MAP_ADMIN+) app.use('/api/map/settings', mapSettingsRouter); // Map settings (public GET, auth PUT) app.use('/api/map/events', eventsPublicRouter); // Public map events from Gancio (no auth) app.use('/api/meeting-planner', meetingPlannerPublicRouter); // Public poll viewing + voting (no auth) app.use('/api/meeting-planner', meetingPlannerAdminRouter); // Admin poll CRUD (auth required) app.use('/api/straw-polls', strawPollPublicRouter); // Public straw poll voting + viewing (no auth) app.use('/api/straw-polls', strawPollWidgetRouter); // Straw poll widget endpoint (no auth, cached) app.use('/api/straw-polls', strawPollAdminRouter); // Admin straw poll CRUD (auth required) app.use('/api/meetings/agendas', agendaRouter); // Meeting agendas + minutes (EVENTS roles) app.use('/api/meetings/action-items', actionItemsRouter); // Action items CRUD (EVENTS roles / auth) app.use('/api/qr', qrRouter); // QR code generation (public) app.use('/api/listmonk', listmonkWebhookRouter); // Listmonk webhook (shared secret, no JWT) app.use('/api/listmonk', listmonkRouter); // Listmonk newsletter sync (SUPER_ADMIN) app.use('/api/email-templates', emailTemplatesRouter); // Email template management (ADMIN roles) app.use('/api/pages', pagesPublicRouter); // Public landing pages (no auth) app.use('/api/pages', pagesAdminRouter); // Admin landing page CRUD (auth required) app.use('/api/page-blocks', blocksRouter); // Admin page block library (auth required) app.use('/api/docs', docsRouter); // Docs status + config (auth required) app.use('/api/docs-access', docsAccessRouter); // Docs access policies + share links app.use('/api/gitea/setup', giteaSetupRouter); // Gitea auto-setup (SUPER_ADMIN) app.use('/api/services', servicesRouter); // Platform services status (SUPER_ADMIN) app.use('/api/map/canvass', canvassVolunteerRouter); // Volunteer canvass routes (auth required) app.use('/api/map/canvass', canvassAdminRouter); // Admin canvass routes (MAP_ADMIN+) app.use('/api/map/canvass', canvassExportRouter); // Canvass-to-campaign export (admin roles) app.use('/api/map/tracking', trackingVolunteerRouter); // Volunteer GPS tracking (auth required) app.use('/api/map/tracking', trackingAdminRouter); // Admin GPS tracking (MAP_ADMIN+) app.use('/api/settings', siteSettingsRouter); // Site settings (public GET, SUPER_ADMIN PUT) app.use('/api/pangolin', pangolinRouter); // Pangolin tunnel management (SUPER_ADMIN) app.use('/api/ccp-registration', ccpRegistrationRouter); // CCP remote management registration (SUPER_ADMIN) app.use('/api/rocketchat', rocketchatRouter); // Rocket.Chat SSO + status (auth required) app.use('/api/jitsi', jitsiRouter); // Jitsi Meet JWT + status (auth required) app.use('/api/observability', observabilityRouter); // Observability / monitoring (SUPER_ADMIN) app.use('/api/upgrade', upgradeRouter); // System upgrade management (SUPER_ADMIN) app.use('/api/registry', registryRouter); // Container registry management (SUPER_ADMIN) app.use('/api/dashboard', dashboardRouter); // Dashboard summary (ADMIN roles) app.use('/api/donation-pages', donationPagesPublicRouter); // Public donation pages (no auth) app.use('/api/payments', paymentsPublicRouter); // Public payment routes (plans, checkout, my subscription) app.use('/api/payments/admin/donation-pages', donationPagesAdminRouter); // Admin donation page CRUD (SUPER_ADMIN) app.use('/api/payments/admin', paymentsAdminRouter); // Admin payment management (SUPER_ADMIN) app.use('/api/petitions', petitionsPublicRouter); // Public petition listing + signing (no auth) app.use('/api/petitions', petitionVerifyRouter); // Petition email verification (no auth) app.use('/api/petitions', petitionsAdminRouter); // Admin petition CRUD (INFLUENCE_ROLES) app.use('/api/influence/effectiveness', effectivenessRouter); // Campaign effectiveness analytics (ADMIN) app.use('/api/action-campaigns', actionCampaignsRouter); // Public action campaign (optional auth) + step complete (auth) app.use('/api/admin/action-campaigns', actionCampaignsAdminRouter); // Admin action campaign CRUD (INFLUENCE_ROLES) app.use('/api/volunteer', volunteerDashboardRouter); // Volunteer dashboard aggregator (auth required) app.use('/api/gallery-ads', galleryAdsPublicRouter); // Public gallery ads (optional auth) app.use('/api/gallery-ads/admin', galleryAdsAdminRouter); // Admin gallery ad CRUD (SUPER_ADMIN) app.use('/api/docs-analytics', docsAnalyticsPublicRouter); // Public docs page view tracking (no auth) app.use('/api/docs-analytics', docsAnalyticsAdminRouter); // Admin docs analytics (ADMIN roles) app.use('/api/analytics', analyticsUserRouter); // User self-service analytics (any auth) app.use('/api/analytics', analyticsAdminRouter); // Admin unified analytics (ANALYTICS_ROLES) app.use('/api/docs-comments', docsCommentsPublicRouter); // Public docs comments (CORS override for docs origin) app.use('/api/docs-comments', docsCommentsAdminRouter); // Admin docs comment moderation (ADMIN roles) app.use('/api/volunteer-invite', volunteerInviteRouter); // Quick join invite (admin generate + public redeem) app.use('/api/sms/contacts', smsContactsRouter); // SMS contact list CRUD + import (ADMIN roles) app.use('/api/sms/campaigns', smsCampaignsRouter); // SMS campaign CRUD + execution (ADMIN roles) app.use('/api/sms/conversations', smsConversationsRouter); // SMS conversation threads (ADMIN roles) app.use('/api/sms/messages', smsMessagesRouter); // SMS message history + ad-hoc send (ADMIN roles) app.use('/api/sms/device', smsDeviceRouter); // SMS device status + sync trigger (ADMIN roles) app.use('/api/sms/templates', smsTemplatesRouter); // SMS template CRUD (ADMIN roles) app.use('/api/sms/setup', smsSetupRouter); // SMS setup wizard (SUPER_ADMIN only) app.use('/api/profile', profilePublicRouter); // Self-service contact profile (no auth, token-based) app.use('/api/people/needs', participantNeedsRouter); // Participant needs (self-service + EVENTS roles) app.use('/api/people', peopleRouter); // People CRM aggregation (ADMIN roles) app.use('/api/search', searchRouter); // Public unified search (no auth, rate-limited) app.use('/api/activity', activityPublicRouter); // Public activity feed (no auth) app.use('/api/newsletter', newsletterPublicRouter); // Public newsletter subscribe (no auth, rate-limited) app.use('/api/events', eventsListPublicRouter); // Public events list from Gancio (no auth, cached) app.use('/api/homepage', homepageRouter); // Public homepage aggregation (no auth, cached) app.use('/api/og', ogRouter); // OG meta tags for social sharing bots (no auth, cached) app.use('/api/social', socialRouter); // Social connections (auth required) app.use('/api/ticketed-events/admin', ticketedEventsAdminRouter); // Admin ticketed event CRUD (auth + permission) — MUST be before public /:slug app.use('/api/ticketed-events/checkin', checkinRouter); // Check-in scanner routes (auth required) app.use('/api/ticketed-events', ticketedEventsPublicRouter); // Public ticketed event listing + checkout (no auth) app.use('/api/public/error-report', errorReportRouter); // Public 404 error reporting (rate-limited) app.use('/api/admin/calendar/shared', adminCalendarRouter); // Admin calendar views (SUPER_ADMIN/MAP_ADMIN) app.use('/api/calendar/shared', sharedCalendarRoutes); // Shared calendar views + collaboration (public share + auth) app.use('/api/calendar', feedRoutes); // ICS feed subscriptions + export (public .ics + auth) app.use('/api/calendar', calendarRoutes); // Personal calendar layers + items (auth required) // --- API 404 Handler (catch unmatched /api/* routes) --- app.use('/api/*', (_req, res) => { res.status(404).json({ error: { message: 'Route not found', code: 'NOT_FOUND' } }); }); // --- Error Handler (must be last) --- app.use(errorHandler); // --- Start Server --- async function start() { try { await prisma.$connect(); logger.info('Database connected'); // Initialize encryption for DB-stored secrets (SMTP password, etc.) initEncryption(env.ENCRYPTION_KEY); // Warn if Listmonk sync is enabled but webhook secret is not configured if (env.LISTMONK_SYNC_ENABLED === 'true' && !env.LISTMONK_WEBHOOK_SECRET) { logger.warn('LISTMONK_SYNC_ENABLED is true but LISTMONK_WEBHOOK_SECRET is not set. Unsubscribe events from Listmonk will not be processed.'); } // Register user provisioning framework registerProvisioners(); // Register EventBus listeners (Listmonk, RC, CRM, Calendar, n8n, Gancio) registerAllEventListeners(); // Rebuild SMTP transporter from DB settings (env fallback for empty fields) await emailService.rebuildTransporter(); emailQueueService.startWorker(); notificationQueueService.startWorker(); geocodeQueueService.startWorker(); calendarFeedQueueService.startWorker(); scheduledJobsQueueService.startWorker(); pollAutoFinalizeQueueService.startWorker(); pollAutoCloseQueueService.startWorker(); startProxy(); // Load SMS config from DB (env fallback for empty fields) await termuxClient.configureFromDb(); // Load Tailscale config from DB (for SMS setup device discovery) const { tailscaleClient } = await import('./services/tailscale.client'); await tailscaleClient.configureFromDb(); // Start SMS services if enabled if (termuxClient.enabled) { smsQueueService.startWorker(); smsResponseSyncService.start(); smsDeviceMonitorService.start(); logger.info('SMS integration enabled (Termux API)'); } // One-time startup calls (recurring runs handled by scheduled-jobs queue) verificationTokenService.cleanupExpiredTokens().catch(() => {}); passwordResetTokenService.cleanupExpiredTokens().catch(() => {}); canvassService.closeAbandonedSessions().catch(() => {}); trackingService.cleanupOldData(30).catch(() => {}); trackingService.closeStaleTrackingSessions(120).catch(() => {}); docsAnalyticsService.cleanupOldData(90).catch(() => {}); reengagementService.scan().catch(() => {}); socialDigestService.scan().catch(() => {}); // Gitea auto-setup (if admin password is provided, auto-configure token + repos) giteaSetupService.autoSetupIfNeeded().catch(() => {}); // SSE + Presence: mark all users offline on startup, start heartbeat + stale cleanup presenceService.markAllOffline().catch(() => {}); sseService.startHeartbeat(); pollSseService.startHeartbeat(); setInterval(() => presenceService.cleanupStale().catch(() => {}), 60 * 1000); // every 1 min // Challenge lifecycle: activate/complete/score every 5 minutes import('./services/challenge-scoring.service').then(({ challengeScoringService }) => { challengeScoringService.processLifecycle().catch(() => {}); setInterval(() => challengeScoringService.processLifecycle().catch(() => {}), 5 * 60 * 1000); logger.info('Challenge lifecycle processor started (every 5min)'); }).catch(() => {}); // Clean up stale upgrade progress on startup upgradeService.clearStaleProgress(); // Archive any pending upgrade result to history + send notifications autoUpgradeService.handlePostRestartResult().catch(() => {}); // Start auto-upgrade scheduler if enabled autoUpgradeService.start().catch(() => {}); // Setup Rocket.Chat notification channels (non-blocking) rocketchatWebhookService.setupChannels().catch(() => {}); // Sync CML site settings → Gancio (with retry for slow Gancio startup) if (gancioClient.enabled) { const maxAttempts = 3; const retryDelay = 20000; // 20s — covers Gancio's 60s start_period const attemptSync = async (attempt: number): Promise => { try { const available = await gancioClient.isAvailable(); if (!available) throw new Error('Gancio not available'); await gancioSettingsSyncService.syncAll(); } catch { if (attempt < maxAttempts) { logger.debug(`Gancio settings sync attempt ${attempt}/${maxAttempts} failed, retrying in ${retryDelay / 1000}s`); setTimeout(() => attemptSync(attempt + 1).catch(() => {}), retryDelay); } else { logger.warn(`Gancio settings sync failed after ${maxAttempts} attempts — will sync on next settings update`); } } }; attemptSync(1).catch(() => {}); } // Sync MkDocs overrides on startup pagesService.syncOverrides() .then(({ imported, updated }) => { if (imported > 0 || updated > 0) { logger.info(`Startup sync: imported ${imported}, updated ${updated} MkDocs overrides`); } }) .catch((err) => { logger.warn('Startup sync of MkDocs overrides failed:', err); }); // Check for docs reset flag (set by config.sh during setup) const docsResetFlagPath = path.resolve(path.dirname(env.MKDOCS_CONFIG_PATH), '.reset-docs-on-startup'); if (existsSync(docsResetFlagPath)) { const { docsResetService } = await import('./modules/docs/docs-reset.service'); docsResetService.resetToBaseline() .then((result) => { logger.info(`Docs reset completed: ${result.filesReset} files reset, ${result.filesPreserved} preserved`); unlinkSync(docsResetFlagPath); }) .catch((err) => logger.warn('Docs reset from config flag failed:', err)); } // Validate MkDocs exports on startup (recurring runs handled by scheduled-jobs queue) pagesService.validateExports() .then(({ validated, repaired, errors }) => { if (repaired > 0 || errors.length > 0) { logger.info(`Validation: ${validated} checked, ${repaired} repaired, ${errors.length} errors`); } }) .catch((err) => logger.warn('Validation failed:', err)); const server = app.listen(env.PORT, () => { logger.info(`API server running on port ${env.PORT} [${env.NODE_ENV}]`); }); // --- WebSocket upgrade handler for docs collaboration --- const wss = new WebSocketServer({ noServer: true }); server.on('upgrade', (request, socket, head) => { const url = new URL(request.url || '', `http://${request.headers.host}`); // HocuspocusProvider connects to the base URL; document name is sent via protocol const collabPath = '/api/docs/collaborate'; if (url.pathname !== collabPath && !url.pathname.startsWith(collabPath + '/')) { socket.destroy(); return; } wss.handleUpgrade(request, socket, head, (ws) => { // Document name may be in the URL path (y-websocket) or sent via protocol (HocuspocusProvider) const pathSuffix = url.pathname.slice(collabPath.length + 1); // strip /api/docs/collaborate/ const documentName = pathSuffix ? decodeURIComponent(pathSuffix) : ''; const token = url.searchParams.get('token') || ''; docsCollabService.handleConnection(ws, request, { documentName, token }); }); }); // Clean stale collab states on startup (recurring runs handled by scheduled-jobs queue) docsCollabService.cleanupStaleStates().catch(() => {}); } catch (err) { logger.error('Failed to start server:', err); process.exit(1); } } start(); // Graceful shutdown for (const signal of ['SIGTERM', 'SIGINT']) { process.on(signal, async () => { logger.info(`${signal} received, shutting down...`); sseService.closeAll(); pollSseService.closeAll(); await docsCollabService.shutdown(); await stopProxy(); await emailQueueService.close(); await notificationQueueService.close(); await geocodeQueueService.close(); await smsQueueService.close(); await calendarFeedQueueService.close(); await scheduledJobsQueueService.close(); await prisma.$disconnect(); redis.disconnect(); process.exit(0); }); } export { app };