import type { Req, Res, Next } from './types/http'; const express = require('express'); const { log } = require('./utils/logger.cts'); const cookieParser = require('cookie-parser'); const path = require('path'); const { getDb } = require('./db/database.cts'); const { requireAuth, requireUser, requireAdmin } = require('./middleware/requireAuth.cts'); const { recordError } = require('./services/statusRuntime.cts'); const { securityHeaders } = require('./middleware/securityHeaders.cts'); const { logAudit } = require('./services/auditService.cts'); const { errorFormatter } = require('./middleware/errorFormatter.cts'); const { importLimiter, exportLimiter, adminActionLimiter, oidcLimiter, loginLimiter, loginUsernameLimiter, passwordLimiter, backupOperationLimiter, } = require('./middleware/rateLimiter.cts'); const { csrfMiddleware, csrfTokenProvider } = require('./middleware/csrf.cts'); const app = express(); const PORT = process.env.PORT || 3000; const HOST = process.env.BIND_HOST || '0.0.0.0'; const DIST = path.join(__dirname, 'dist'); // ── Trust proxy ─────────────────────────────────────────────────────────────── // Required when running behind a reverse proxy (Docker, nginx, Traefik, etc.) so // req.ip reflects the real client (rate limiting, audit logs) and req.secure // reflects the original protocol (Secure cookies). Examples: // TRUST_PROXY=true → trust first proxy hop (most common) // TRUST_PROXY=2 → trust two hops // TRUST_PROXY=loopback → trust loopback addresses only // Unset/false → no proxy trusted (direct deployment). const TRUST_PROXY = (process.env.TRUST_PROXY || '').trim(); if (TRUST_PROXY && !/^(false|0|no|off)$/i.test(TRUST_PROXY)) { if (/^(true|yes|on)$/i.test(TRUST_PROXY)) { app.set('trust proxy', 1); } else if (/^\d+$/.test(TRUST_PROXY)) { app.set('trust proxy', parseInt(TRUST_PROXY, 10)); } else { app.set('trust proxy', TRUST_PROXY); // 'loopback', named subnet, or CIDR list } } // ── Security headers (applied to every response) ────────────────────────────── app.use(securityHeaders); // ── CORS — disabled unless CORS_ORIGIN is explicitly configured ─────────────── // The default same-origin deployment (Express serves both API and UI) needs no // CORS headers. Set CORS_ORIGIN=https://your-frontend.example.com only when // the frontend is hosted on a separate origin. if (process.env.CORS_ORIGIN) { const cors = require('cors'); const allowed = process.env.CORS_ORIGIN.split(',') .map((s) => s.trim()) .filter(Boolean); app.use(cors({ credentials: true, origin: allowed })); } app.use(express.json({ limit: '100kb' })); // import routes override this per-endpoint app.use(cookieParser()); // ── CSRF token provider - sets CSRF cookie on every response ──────────────── // This ensures the CSRF token cookie is always present for API clients app.use(csrfTokenProvider); // ── Error response formatter ───────────────────────────────────────────────── // Patches res.json so all error bodies follow the standardized format. // Must be mounted BEFORE the routes so the patch is in place when they respond. app.use(errorFormatter); // ── Health check ──────────────────────────────────────────────────────────── // Unauthenticated liveness probe — used by Docker healthchecks and by the // mobile app's local mode (nodejs-mobile) to detect when the embedded server // is ready to serve requests. app.get('/api/health', (req: Req, res: Res) => { res.json({ ok: true }); }); // ── API ─────────────────────────────────────────────────────────────────────── // Auth — rate limiters applied at middleware level to prevent bypass // Login endpoint is public entry point, exempt from CSRF (no session to hijack yet) // Note: passwordLimiter is NOT applied here — it's for password change endpoints // Helper: skip rate limiting if no users exist (first-run scenario) function skipRateLimitIfNoUsers(limiter: any) { return (req: Req, res: Res, next: Next) => { try { const db = getDb(); const userCount = db.prepare('SELECT COUNT(*) AS count FROM users').get().count; if (userCount === 0) { return next(); // first run — no rate limiting } } catch (err: any) { // DB not ready yet — allow request to proceed log.info('[skipRateLimit] DB not initialized, allowing request'); return next(); } // User exists — apply rate limiter return limiter(req, res, next); }; } // Mount login router with conditional rate limiting // If no users exist, rate limit is bypassed; otherwise it applies. // Two layers: per-IP (all attempts) and per-username (failed attempts only), // so one IP can't burn 10 tries against every account, and a distributed // attacker can't brute-force a single account from many IPs. app.use('/api/auth/login', skipRateLimitIfNoUsers(loginLimiter)); app.use('/api/auth/login', skipRateLimitIfNoUsers(loginUsernameLimiter)); // Login skips CSRF inside routes/auth because no authenticated session exists yet. // Authenticated state-changing auth routes, including logout-all and password // changes, require the SPA's x-csrf-token header like other mutating requests. // All other auth routes require CSRF (loginLimiter applied via /api/auth/login above) // Note: passwordLimiter is applied individually on routes that actually change passwords app.use('/api/auth', csrfMiddleware, require('./routes/auth.cts')); // OIDC — rate-limited; returns 501 gracefully when OIDC is not configured app.use('/api/auth/oidc', csrfMiddleware, oidcLimiter, require('./routes/authOidc.cts')); // Admin — all routes already require auth+admin; mutation-heavy routes get // an additional per-IP rate limit applied to the whole admin namespace // Backup operations have dedicated rate limiting (5 per hour) to prevent resource exhaustion // NOTE: backupOperationLimiter is applied per-route in routes/admin.js to avoid blocking non-backup admin actions app.use( '/api/admin', csrfMiddleware, requireAuth, requireAdmin, adminActionLimiter, require('./routes/admin.cts'), ); app.use('/api/tracker', csrfMiddleware, requireAuth, requireUser, require('./routes/tracker.cts')); app.use('/api/bills', csrfMiddleware, requireAuth, requireUser, require('./routes/bills.cts')); app.use( '/api/subscriptions', csrfMiddleware, requireAuth, requireUser, require('./routes/subscriptions.cts'), ); app.use( '/api/payments', csrfMiddleware, requireAuth, requireUser, require('./routes/payments.cts'), ); app.use( '/api/data-sources', csrfMiddleware, requireAuth, requireUser, require('./routes/dataSources.cts'), ); app.use( '/api/transactions', csrfMiddleware, requireAuth, requireUser, require('./routes/transactions.cts'), ); app.use('/api/matches', csrfMiddleware, requireAuth, requireUser, require('./routes/matches.cts')); app.use( '/api/categories', csrfMiddleware, requireAuth, requireUser, require('./routes/categories.cts'), ); app.use( '/api/settings', csrfMiddleware, requireAuth, requireUser, require('./routes/settings.cts'), ); app.use('/api/user', csrfMiddleware, requireAuth, requireUser, require('./routes/user.cts')); app.use('/api/calendar', require('./routes/calendarFeed.cts')); app.use( '/api/calendar', csrfMiddleware, requireAuth, requireUser, require('./routes/calendar.cts'), ); app.use('/api/summary', csrfMiddleware, requireAuth, requireUser, require('./routes/summary.cts')); app.use( '/api/monthly-starting-amounts', csrfMiddleware, requireAuth, requireUser, require('./routes/monthly-starting-amounts.cts'), ); app.use( '/api/analytics', csrfMiddleware, requireAuth, requireUser, require('./routes/analytics.cts'), ); app.use( '/api/spending', csrfMiddleware, requireAuth, requireUser, require('./routes/spending.cts'), ); app.use( '/api/snowball', csrfMiddleware, requireAuth, requireUser, require('./routes/snowball.cts'), ); app.use('/api/notifications', csrfMiddleware, requireAuth, require('./routes/notifications.cts')); app.use('/api/status', csrfMiddleware, requireAuth, requireAdmin, require('./routes/status.cts')); app.use('/api/about', require('./routes/about.cts')); // public app.use( '/api/about-admin', adminActionLimiter, csrfMiddleware, requireAuth, requireAdmin, require('./routes/aboutAdmin.cts'), ); // admin-only app.use('/api/privacy', require('./routes/privacy.cts')); // public app.use('/api/version', require('./routes/version.cts')); // public // Profile — rate limit only on password-change, not all profile reads app.use('/api/profile', csrfMiddleware, requireAuth, requireUser, require('./routes/profile.cts')); // Export / Import — per-IP rate limited to deter abuse and resource exhaustion const importRoutes = require('./routes/import.cts'); app.use( '/api/export', csrfMiddleware, requireAuth, requireUser, exportLimiter, require('./routes/export.cts'), ); app.use('/api/import', csrfMiddleware, requireAuth, requireUser, importLimiter, importRoutes); app.use('/api/imports', csrfMiddleware, requireAuth, requireUser, importLimiter, importRoutes); // ── API 404 — unknown /api/* routes must return JSON, not the SPA shell ────── // Without this, the catch-all below serves index.html with HTTP 200 for any // unrecognized API path, which API clients then fail to parse as JSON. app.all('/api/*splat', (req: Req, res: Res) => { res.status(404).json({ error: 'NOT_FOUND', message: 'Unknown API route', code: 'NOT_FOUND', }); }); // ── Retired legacy UI ──────────────────────────────────────────────────────── app.all(['/legacy', '/legacy/*splat'], (req: Req, res: Res) => { res.status(410).send('The legacy UI has been retired.'); }); // ── Modern UI (Vite build) ──────────────────────────────────────────────────── app.get('/login.html', (req: Req, res: Res) => res.redirect(302, '/login')); app.use(express.static(DIST)); // Ensure CSRF cookie is set for SPA by calling getCsrfToken before sending index.html const { getCsrfToken } = require('./middleware/csrf.cts'); app.get('/{*splat}', (req: Req, res: Res) => { // Set CSRF cookie if not present (needed for SPA to read token) getCsrfToken(req, res); // root-option form: send >= 2026-07 rejects bare absolute paths (4a dep sweep) res.sendFile('index.html', { root: DIST }); }); // ── Global error handler ────────────────────────────────────────────────────── // Never expose stack traces, internal paths, or raw error objects in responses. app.use((err: any, req: Req, res: Res, next: Next) => { // If a response was already (partially) sent, we can't write a new body/status — // delegate to Express's default handler, which closes the connection cleanly. if (res.headersSent) return next(err); const status = err.status || 500; // Only server errors (5xx) are logged/recorded; 4xx are expected client errors // (validation, auth, not-found) and must not spam the error tracker. if (status >= 500) { recordError('Express', err); log.error('[error]', err.stack || err.message || String(err)); } if (/^\/api\/imports?\//.test(req.path || '')) { const isBodyError = err.type === 'entity.too.large' || err instanceof SyntaxError; const code = isBodyError ? 'IMPORT_REQUEST_ERROR' : 'IMPORT_SERVER_ERROR'; return res.status(err.status || (isBodyError ? 400 : 500)).json({ error: code, message: isBodyError ? 'The import request could not be read. Please retry with a smaller or valid file.' : 'Unexpected import server error. Please try again.', code, }); } // Single canonical error body: { error, message, code, field? } (utils/apiError). const { formatError } = require('./utils/apiError.cts'); res.status(status).json(formatError(err)); }); // ── Safety net: unhandled promise rejections — log + record, don't crash ───── // (Most rejections here are non-fatal side effects; a crash would be worse than // surfacing them. Truly fatal states are handled by uncaughtException below.) process.on('unhandledRejection', (reason: any) => { log.error('[server] Unhandled promise rejection:', reason?.stack || reason?.message || reason); try { recordError('unhandledRejection', reason instanceof Error ? reason : new Error(String(reason))); } catch {} }); // An uncaught exception leaves the process in an undefined state — record it and // exit non-zero so the supervisor (Docker `restart: unless-stopped`) restarts clean. process.on('uncaughtException', (err) => { log.error('[server] Uncaught exception:', err?.stack || err); try { recordError('uncaughtException', err); } catch {} process.exit(1); }); // ── Graceful shutdown ──────────────────────────────────────────────────────── // Docker `stop` sends SIGTERM. Stop accepting connections, let in-flight requests // finish, then checkpoint the WAL and close the DB so no write is lost. Hard-exit // if it stalls. let shuttingDown = false; function gracefulShutdown(signal: any, server: any) { if (shuttingDown) return; shuttingDown = true; log.info(`[server] ${signal} received — shutting down gracefully`); const finish = (code: any) => { try { const { getDb, closeDb } = require('./db/database.cts'); try { getDb().pragma('wal_checkpoint(TRUNCATE)'); } catch {} closeDb(); log.info('[server] database closed cleanly'); } catch (e: any) { log.error('[server] error closing database:', e?.message || e); } process.exit(code); }; server.close(() => finish(0)); // Don't let a hung connection block shutdown forever. setTimeout(() => { log.error('[server] shutdown timed out after 10s — forcing exit'); finish(1); }, 10000).unref(); } // ── Bootstrap ───────────────────────────────────────────────────────────────── async function main() { // Fail fast on invalid configuration; warn on security-relevant gaps. require('./utils/env.cts').validateEnv(); const db = getDb(); // Migrate DB-key-encrypted secrets to env key when TOKEN_ENCRYPTION_KEY is set const { reEncryptWithEnvKey } = require('./services/encryptionService.cts'); try { reEncryptWithEnvKey(db); } catch (err: any) { log.error('[encryption] Startup key migration failed:', err.message); } // Run session cleanup on startup const { cleanupExpiredSessions } = require('./db/database.cts'); try { log.info('[cleanup] Running session cleanup on startup'); cleanupExpiredSessions(); } catch (err: any) { log.error('[cleanup-error] Failed to run startup session cleanup:', err.message); } const userCount = db.prepare('SELECT COUNT(*) AS count FROM users').get().count; if (userCount === 0) await require('./setup/firstRun.cts').run(db); // [seed] Check for and create regular user if INIT_REGULAR_USER/INIT_REGULAR_PASS are set if (process.env.INIT_REGULAR_USER && process.env.INIT_REGULAR_PASS) { const regularUser = process.env.INIT_REGULAR_USER; const regularPass = process.env.INIT_REGULAR_PASS; // Validate password length if (regularPass && regularPass.length < 8) { log.error('[seed] INIT_REGULAR_PASS must be at least 8 characters'); process.exit(1); } // Wrap user creation in a transaction to prevent race conditions const createRegularUser = db.transaction(() => { const existingRegular = db .prepare('SELECT id FROM users WHERE username = ?') .get(regularUser); if (!existingRegular) { const bcrypt = require('bcryptjs'); const regularHash = bcrypt.hashSync(regularPass, 12); db.prepare( ` INSERT INTO users (username, password_hash, role, first_login, must_change_password, is_default_admin) VALUES (?, ?, 'user', 0, 0, 0) `, ).run(regularUser, regularHash); log.info(`[seed] Regular user "${regularUser}" created.`); return true; } else { // Update existing regular user's password and reset flags const bcrypt = require('bcryptjs'); const regularHash = bcrypt.hashSync(regularPass, 12); db.prepare( 'UPDATE users SET password_hash = ?, first_login = 0, must_change_password = 0 WHERE id = ?', ).run(regularHash, existingRegular.id); logAudit({ user_id: existingRegular.id, action: 'seed.flag_reset', entity_type: 'user', details: { username: regularUser, flags: ['first_login', 'must_change_password'], source: 'server-seed', }, }); log.info(`[seed] Regular user "${regularUser}" password updated and flags reset.`); return false; } }); createRegularUser(); } const server = app.listen(PORT, HOST, () => { log.info(`Bill Tracker running on ${HOST}:${PORT}`); if (userCount > 0) log.info(`Users found: ${userCount}`); // Set up periodic session cleanup const { cleanupExpiredSessions } = require('./db/database.cts'); const rawInterval = process.env.SESSION_CLEANUP_INTERVAL_MS; let CLEANUP_INTERVAL_MS = 86400000; // 24 hours default if (rawInterval !== undefined) { const parsed = parseInt(rawInterval, 10); if (!isNaN(parsed) && parsed > 0 && parsed <= 604800000) { // max 7 days CLEANUP_INTERVAL_MS = parsed; } else { log.warn( `[cleanup] Invalid SESSION_CLEANUP_INTERVAL_MS: "${rawInterval}". Using default 24h.`, ); } } // Run cleanup periodically setInterval(() => { try { log.info('[cleanup] Running periodic session cleanup'); cleanupExpiredSessions(); } catch (err: any) { log.error('[cleanup-error] Failed to run periodic session cleanup:', err.message); } }, CLEANUP_INTERVAL_MS); log.info(`[cleanup] Scheduled periodic cleanup every ${CLEANUP_INTERVAL_MS}ms`); // Start SimpleFIN auto-sync worker (no-op when bank sync is disabled) try { require('./services/bankSyncWorker.cts').start(); } catch (err: any) { log.error('[bankSync] Failed to start auto-sync worker:', err.message); } // Start scheduled database backups only when enabled in Admin settings. try { const backupScheduler = require('./services/backupScheduler.cts'); if (backupScheduler.getScheduleStatus().enabled) { backupScheduler.start(); } } catch (err: any) { log.error('[backupScheduler] Failed to start scheduled backup worker:', err.message); } // Start daily worker (autopay marking, notifications, session pruning, cleanup) try { require('./workers/dailyWorker.cts').start(); } catch (err: any) { log.error('[dailyWorker] Failed to start daily worker:', err.message); } }); // Wire graceful shutdown once the server handle exists. process.on('SIGTERM', () => gracefulShutdown('SIGTERM', server)); process.on('SIGINT', () => gracefulShutdown('SIGINT', server)); } main().catch((err) => { log.error('Failed to start server:', err); process.exit(1); });