From fb9abdb76c565149e6c79a3ed09fcd0a915f47ea Mon Sep 17 00:00:00 2001 From: jubnl Date: Sat, 25 Jul 2026 15:58:02 +0200 Subject: [PATCH] refactor(server): non-DI islands read env via app-config --- server/src/db/database.ts | 9 +++++---- server/src/db/migrations.ts | 5 +++-- server/src/db/seeds.ts | 12 +++++++----- server/src/demo/demo-reset.ts | 4 +++- server/src/demo/demo-seed.ts | 7 ++++--- server/src/index.ts | 26 ++++++++++++++------------ server/src/mcp/config.ts | 25 +++++-------------------- server/src/mcp/index.ts | 16 ++++++++-------- server/src/scheduler.ts | 23 +++++++++++------------ server/src/systemNotices/service.ts | 3 ++- server/src/websocket.ts | 5 ++--- 11 files changed, 64 insertions(+), 71 deletions(-) diff --git a/server/src/db/database.ts b/server/src/db/database.ts index 84f66700a..4a1aed293 100644 --- a/server/src/db/database.ts +++ b/server/src/db/database.ts @@ -1,6 +1,7 @@ import Database from 'better-sqlite3'; import path from 'path'; import fs from 'fs'; +import { readEnv } from '../app-config'; import { createTables } from './schema'; import { runMigrations } from './migrations'; import { runSeeds } from './seeds'; @@ -8,16 +9,16 @@ import { Place, Tag } from '../types'; // In test mode each vitest worker gets an isolated in-memory DB so that // parallel forks can't race on the same file or share migration state. -const isTest = process.env.NODE_ENV === 'test'; +const isTest = readEnv().app.isTest; let dbPath: string; if (isTest) { dbPath = ':memory:'; -} else if (process.env.TREK_DB_FILE) { +} else if (readEnv().db.trekDbFile) { // Explicit DB file (used by the Playwright E2E harness to run against an // isolated, throwaway database instead of the real data/travel.db). Purely // additive — when unset the default path below is used exactly as before. - dbPath = process.env.TREK_DB_FILE; + dbPath = readEnv().db.trekDbFile!; const dir = path.dirname(dbPath); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); } else { @@ -62,7 +63,7 @@ const db = new Proxy({} as Database.Database, { }, }); -if (process.env.DEMO_MODE?.toLowerCase() === 'true') { +if (readEnv().demo.enabled) { try { const { seedDemoData } = require('../demo/demo-seed'); seedDemoData(_db); diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 220934be9..ae1bb0f62 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -1,3 +1,4 @@ +import { readEnv } from '../app-config'; import { encrypt_api_key } from '../services/apiKeyCrypto'; import Database from 'better-sqlite3'; @@ -2720,7 +2721,7 @@ function runMigrations(db: Database.Database): void { `CREATE TABLE IF NOT EXISTS migrations (id integer PRIMARY KEY AUTOINCREMENT NOT NULL, timestamp bigint NOT NULL, name varchar NOT NULL);`, ); db.exec(`INSERT INTO migrations (timestamp, name) VALUES (1777810195344, 'InitialSchema1777810195344');`); - db.exec(`INSERT INTO app_settings (key, value) VALUES ('app_version', '${process.env.APP_VERSION || '3.0.14'}')`); + db.exec(`INSERT INTO app_settings (key, value) VALUES ('app_version', '${readEnv().app.appVersion || '3.0.14'}')`); }, // trim leading/trailing whitespace from stored usernames and emails () => { @@ -2738,7 +2739,7 @@ function runMigrations(db: Database.Database): void { db.exec(`INSERT INTO schema_version_new (version) SELECT version FROM schema_version`); db.exec(`DROP TABLE schema_version`); db.exec(`ALTER TABLE schema_version_new RENAME TO schema_version`); - db.exec(`UPDATE app_settings SET value = '${process.env.APP_VERSION || '3.0.15'}' WHERE key = 'app_version'`); + db.exec(`UPDATE app_settings SET value = '${readEnv().app.appVersion || '3.0.15'}' WHERE key = 'app_version'`); }, // Migration: OAuth 2.0 client_credentials grant — allow user-owned confidential // clients to skip the browser consent flow entirely and obtain tokens directly diff --git a/server/src/db/seeds.ts b/server/src/db/seeds.ts index 8252f3562..cee241872 100644 --- a/server/src/db/seeds.ts +++ b/server/src/db/seeds.ts @@ -1,5 +1,6 @@ import Database from 'better-sqlite3'; import crypto from 'crypto'; +import { readEnv } from '../app-config'; // bcrypt cost factor for the seeded admin password — kept in sync with authService. const BCRYPT_COST = 12; @@ -9,14 +10,15 @@ const BCRYPT_COST = 12; // are only relevant after the first user exists; at that point seeds have already // finished and skip via the userCount > 0 guard above. function isOidcOnlyConfigured(): boolean { - if (process.env.OIDC_ONLY?.toLowerCase() !== 'true') return false; - return !!(process.env.OIDC_ISSUER && process.env.OIDC_CLIENT_ID); + const oidc = readEnv().oidc; + if (!oidc.only) return false; + return !!(oidc.issuer && oidc.clientId); } function seedAdminAccount(db: Database.Database): void { try { - const env_admin_email = process.env.ADMIN_EMAIL; - const env_admin_pw = process.env.ADMIN_PASSWORD; + const env_admin_email = readEnv().adminBootstrap.email; + const env_admin_pw = readEnv().adminBootstrap.password; const adminEnvProvided = !!(env_admin_email || env_admin_pw); const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count; @@ -34,7 +36,7 @@ function seedAdminAccount(db: Database.Database): void { // Demo mode seeds its own admin (admin@trek.app, username 'admin') right after this. // Creating a first-run admin here would grab username 'admin' first and make the demo // seeder fail on the UNIQUE(username) constraint, leaving the demo user uncreated. - if (process.env.DEMO_MODE?.toLowerCase() === 'true') return; + if (readEnv().demo.enabled) return; if (isOidcOnlyConfigured()) { console.log(''); diff --git a/server/src/demo/demo-reset.ts b/server/src/demo/demo-reset.ts index bcd04983a..45b2812e4 100644 --- a/server/src/demo/demo-reset.ts +++ b/server/src/demo/demo-reset.ts @@ -1,5 +1,6 @@ import fs from 'fs'; import path from 'path'; +import { readEnv } from '../app-config'; const dataDir = path.join(__dirname, '../../data'); const dbPath = path.join(dataDir, 'travel.db'); @@ -14,7 +15,8 @@ function resetDemoUser(): void { const { db, closeDb, reinitialize } = require('../db/database'); // Save admin's current credentials and API keys (these should survive the reset) - const adminEmail = process.env.DEMO_ADMIN_EMAIL || 'admin@nomad.app'; + // NOTE: different default than demo-seed (admin@trek.app) — pinned legacy quirk. + const adminEmail = readEnv().demo.adminEmailRaw || 'admin@nomad.app'; interface AdminData { password_hash: string; maps_api_key: string | null; openweather_api_key: string | null; unsplash_api_key: string | null; avatar: string | null; } let adminData: AdminData | undefined = undefined; try { diff --git a/server/src/demo/demo-seed.ts b/server/src/demo/demo-seed.ts index feb65c8c1..f9cd553e4 100644 --- a/server/src/demo/demo-seed.ts +++ b/server/src/demo/demo-seed.ts @@ -1,10 +1,11 @@ import bcrypt from 'bcryptjs'; import Database from 'better-sqlite3'; +import { readEnv } from '../app-config'; function seedDemoData(db: Database.Database): { adminId: number; demoId: number } { - const ADMIN_USER = process.env.DEMO_ADMIN_USER || 'admin'; - const ADMIN_EMAIL = process.env.DEMO_ADMIN_EMAIL || 'admin@trek.app'; - const ADMIN_PASS = process.env.DEMO_ADMIN_PASS || 'admin12345'; + const ADMIN_USER = readEnv().demo.adminUser; + const ADMIN_EMAIL = readEnv().demo.adminEmailRaw || 'admin@trek.app'; + const ADMIN_PASS = readEnv().demo.adminPass; const DEMO_EMAIL = 'demo@trek.app'; const DEMO_PASS = 'demo12345'; diff --git a/server/src/index.ts b/server/src/index.ts index 1e46343c0..6668eeea0 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -24,17 +24,19 @@ const tmpDir = path.join(__dirname, '../data/tmp'); }); import * as scheduler from './scheduler'; +import { readEnv } from './app-config'; import { getAppUrl, getMcpSafeUrl } from './services/notifications'; -const PORT = Number(process.env.PORT) || 3001; -const HOST = process.env.HOST; -const APP_VERSION: string = process.env.APP_VERSION || (require('../package.json') as { version: string }).version; +const PORT = readEnv().app.port; +const HOST = readEnv().app.host; +const APP_VERSION: string = readEnv().app.appVersion || (require('../package.json') as { version: string }).version; const onListen = () => { const { logInfo: sLogInfo, logWarn: sLogWarn } = require('./services/auditLog'); - const LOG_LVL = (process.env.LOG_LEVEL || 'info').toLowerCase(); - const tz = process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; - const origins = process.env.ALLOWED_ORIGINS || '(same-origin)'; + const env = readEnv(); + const LOG_LVL = (env.app.logLevel || 'info').toLowerCase(); + const tz = env.app.tz || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; + const origins = env.http.allowedOriginsRaw || '(same-origin)'; const appUrl = getAppUrl(); const resolvedAppUrl = getMcpSafeUrl(); const banner = [ @@ -44,7 +46,7 @@ const onListen = () => { ...(HOST ? [` Host: ${HOST}`] : []), ` Container Port: ${PORT}`, ` App URL: ${appUrl}`, - ` Environment: ${process.env.NODE_ENV?.toLowerCase() || 'development'}`, + ` Environment: ${env.app.nodeEnv?.toLowerCase() || 'development'}`, ` Timezone: ${tz}`, ` Origins: ${origins}`, ` Log level: ${LOG_LVL}`, @@ -55,12 +57,12 @@ const onListen = () => { ]; banner.forEach(l => console.log(l)); sLogInfo('NestJS serving all routes (Express decommissioned)'); - if (process.env.APP_URL) { + if (env.app.appUrl) { let parsedAppUrl: URL | null = null; - try { parsedAppUrl = new URL(process.env.APP_URL); } catch { /* invalid */ } + try { parsedAppUrl = new URL(env.app.appUrl); } catch { /* invalid */ } if (!parsedAppUrl) { - sLogWarn(`APP_URL: "${process.env.APP_URL}" is not a valid URL — it will be ignored.`); + sLogWarn(`APP_URL: "${env.app.appUrl}" is not a valid URL — it will be ignored.`); } const mcpSafe = parsedAppUrl !== null && ( @@ -72,8 +74,8 @@ const onListen = () => { sLogWarn(`APP_URL: not MCP-safe (requires https:// or http://localhost) — MCP will use ${resolvedAppUrl}.`); } } - if (process.env.DEMO_MODE?.toLowerCase() === 'true') sLogInfo('Demo mode: ENABLED'); - if (process.env.DEMO_MODE?.toLowerCase() === 'true' && process.env.NODE_ENV?.toLowerCase() === 'production') { + if (env.demo.enabled) sLogInfo('Demo mode: ENABLED'); + if (env.demo.enabled && env.app.isProduction) { sLogWarn('SECURITY WARNING: DEMO_MODE is enabled in production!'); } scheduler.start(); diff --git a/server/src/mcp/config.ts b/server/src/mcp/config.ts index ff2ece30b..09a4bc4ca 100644 --- a/server/src/mcp/config.ts +++ b/server/src/mcp/config.ts @@ -1,22 +1,7 @@ /** - * MCP tuning knobs from the environment (#1414). Pure parsers, kept free of - * imports so units can test them without dragging in the MCP SDK. + * MCP tuning knobs from the environment (#1414). The parsers moved into the + * app-config layer (src/app-config/parsers.ts) — this module re-exports them + * so existing import paths keep working. mcp/index.ts consumes the derived + * values via readEnv().mcp. */ - -/** - * Session idle TTL in SECONDS via MCP_SESSION_TTL, default 1 hour, clamped to - * 24h so a milliseconds-value typo can't produce a 1000-hour session. - */ -export function resolveSessionTtlMs(raw: string | undefined): number { - const parsed = Number.parseInt(raw ?? ""); - return Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, 24 * 60 * 60) * 1000 : 60 * 60 * 1000; -} - -/** - * SSE keep-alive interval in SECONDS via MCP_SSE_KEEPALIVE, default 25s - * (below common proxy idle timeouts like nginx-ingress's 60s). 0 disables. - */ -export function resolveKeepaliveMs(raw: string | undefined): number { - const parsed = Number.parseInt(raw ?? ""); - return Number.isFinite(parsed) && parsed >= 0 ? parsed * 1000 : 25_000; -} +export { resolveSessionTtlMs, resolveKeepaliveMs } from '../app-config/parsers'; diff --git a/server/src/mcp/index.ts b/server/src/mcp/index.ts index d7c85bae0..ce340b297 100644 --- a/server/src/mcp/index.ts +++ b/server/src/mcp/index.ts @@ -10,7 +10,7 @@ import { ADDON_IDS } from '../addons'; import { registerResources } from './resources'; import { registerTools } from './tools'; import { McpSession, sessions, revokeUserSessions, revokeUserSessionsForClient, evictOldestSessionForUser } from './sessionManager'; -import { resolveSessionTtlMs, resolveKeepaliveMs } from './config'; +import { readEnv } from '../app-config'; import { writeAudit, getClientIp } from '../services/auditLog'; import { getMcpSafeUrl } from '../services/notifications'; @@ -96,11 +96,12 @@ const STATIC_TOKEN_DEPRECATION_NOTICE = 'Please migrate to OAuth 2.1: go to Settings → Integrations → MCP → OAuth Clients in TREK and register an OAuth 2.1 application." ' + 'The actual tool result follows — answer the user\'s question as well.'; -// Configurable session TTL + SSE keep-alive cadence (#1414); see mcp/config.ts. -const SESSION_TTL_MS = resolveSessionTtlMs(process.env.MCP_SESSION_TTL); -const sessionParsed = Number.parseInt(process.env.MCP_MAX_SESSION_PER_USER ?? ""); -const MAX_SESSIONS_PER_USER = Number.isFinite(sessionParsed) && sessionParsed > 0 ? sessionParsed : 20; -const KEEPALIVE_MS = resolveKeepaliveMs(process.env.MCP_SSE_KEEPALIVE); +// Configurable session TTL + SSE keep-alive cadence (#1414). +// Frozen at import on purpose (legacy timing) — MCP tuning knobs are boot-stable. +const mcpEnv = readEnv().mcp; +const SESSION_TTL_MS = mcpEnv.sessionTtlMs; +const MAX_SESSIONS_PER_USER = mcpEnv.maxSessionsPerUser; +const KEEPALIVE_MS = mcpEnv.sseKeepaliveMs; /** * Write SSE comment frames on an interval while the response is an open @@ -130,8 +131,7 @@ function armSseKeepalive(res: Response, touch?: () => void): void { res.once('close', () => clearInterval(timer)); } const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute -const parsed = Number.parseInt(process.env.MCP_RATE_LIMIT ?? ""); -const RATE_LIMIT_MAX = Number.isFinite(parsed) && parsed > 0 ? parsed : 300; // requests per minute per user +const RATE_LIMIT_MAX = mcpEnv.rateLimitMax; // requests per minute per user interface RateLimitEntry { count: number; diff --git a/server/src/scheduler.ts b/server/src/scheduler.ts index b9f7fb872..a1cc1b8d8 100644 --- a/server/src/scheduler.ts +++ b/server/src/scheduler.ts @@ -1,5 +1,6 @@ import cron, { type ScheduledTask } from 'node-cron'; import archiver from 'archiver'; +import { readEnv } from './app-config'; import path from 'node:path'; import fs from 'node:fs'; import { logInfo, logError } from './services/auditLog'; @@ -131,7 +132,7 @@ function start(): void { } const expression = buildCronExpression(settings); - const tz = process.env.TZ || 'UTC'; + const tz = readEnv().app.tz || 'UTC'; currentTask = cron.schedule(expression, runBackup, { timezone: tz }); logInfo(`Auto-Backup scheduled: ${settings.interval} (${expression}), tz: ${tz}, retention: ${settings.keep_days === 0 ? 'forever' : settings.keep_days + ' days'}`); } @@ -141,7 +142,7 @@ let demoTask: ScheduledTask | null = null; function startDemoReset(): void { if (demoTask) { demoTask.stop(); demoTask = null; } - if (process.env.DEMO_MODE?.toLowerCase() !== 'true') return; + if (!readEnv().demo.enabled) return; demoTask = cron.schedule('0 * * * *', () => { try { @@ -177,7 +178,7 @@ function startTripReminders(): void { return; } - const tz = process.env.TZ || 'UTC'; + const tz = readEnv().app.tz || 'UTC'; reminderTask = cron.schedule('0 9 * * *', async () => { try { const { db } = require('./db/database'); @@ -223,7 +224,7 @@ function startTodoReminders(): void { } logInfo(`Todo due reminders: enabled (lead ${TODO_REMINDER_LEAD_DAYS}d)`); - const tz = process.env.TZ || 'UTC'; + const tz = readEnv().app.tz || 'UTC'; todoReminderTask = cron.schedule('0 9 * * *', async () => { try { const { send } = require('./services/notificationService'); @@ -280,7 +281,7 @@ let versionCheckTask: ScheduledTask | null = null; function startVersionCheck(): void { if (versionCheckTask) { versionCheckTask.stop(); versionCheckTask = null; } - const tz = process.env.TZ || 'UTC'; + const tz = readEnv().app.tz || 'UTC'; versionCheckTask = cron.schedule('0 9 * * *', async () => { try { const { checkAndNotifyVersion } = require('./services/adminService'); @@ -296,13 +297,11 @@ function startVersionCheck(): void { // queued mutations with their X-Idempotency-Key when it reconnects, so a key // GC'd before the device comes back online would let the replay create a // duplicate. 24h was far too short for a multi-day offline trip; default 30d, -// overridable via IDEMPOTENCY_TTL_SECONDS. -const DEFAULT_IDEMPOTENCY_TTL_SECONDS = 30 * 24 * 60 * 60; // 30 days +// overridable via IDEMPOTENCY_TTL_SECONDS (default lives in app-config). let idempotencyCleanupTask: ScheduledTask | null = null; function idempotencyTtlSeconds(): number { - const n = Number(process.env.IDEMPOTENCY_TTL_SECONDS); - return Number.isFinite(n) && n > 0 ? n : DEFAULT_IDEMPOTENCY_TTL_SECONDS; + return readEnv().session.idempotencyTtlSeconds; } interface PurgeDb { @@ -324,7 +323,7 @@ function purgeExpiredIdempotencyKeys( function startIdempotencyCleanup(): void { if (idempotencyCleanupTask) { idempotencyCleanupTask.stop(); idempotencyCleanupTask = null; } - const tz = process.env.TZ || 'UTC'; + const tz = readEnv().app.tz || 'UTC'; idempotencyCleanupTask = cron.schedule('0 3 * * *', () => { try { const removed = purgeExpiredIdempotencyKeys(); @@ -379,7 +378,7 @@ function startPlacePhotoCacheCleanup(): void { // Run once on startup to reclaim orphans left over from before this sweeper existed. sweep(); - const tz = process.env.TZ || 'UTC'; + const tz = readEnv().app.tz || 'UTC'; placePhotoCacheTask = cron.schedule('30 3 * * *', sweep, { timezone: tz }); } @@ -395,7 +394,7 @@ function startAirTrailSync(): void { const getSetting = (key: string) => (db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined)?.value; const raw = parseInt(getSetting('airtrail_poll_interval_minutes') || '5', 10); const minutes = Number.isFinite(raw) && raw >= 1 && raw <= 59 ? raw : 5; - const tz = process.env.TZ || 'UTC'; + const tz = readEnv().app.tz || 'UTC'; logInfo(`AirTrail sync: scheduled every ${minutes}m`); airtrailSyncTask = cron.schedule(`*/${minutes} * * * *`, async () => { diff --git a/server/src/systemNotices/service.ts b/server/src/systemNotices/service.ts index 3593934b1..d5eb727d4 100644 --- a/server/src/systemNotices/service.ts +++ b/server/src/systemNotices/service.ts @@ -1,12 +1,13 @@ import { createRequire } from 'module'; import semver from 'semver'; +import { readEnv } from '../app-config'; import { db } from '../db/database.js'; import { SYSTEM_NOTICES } from './registry.js'; import { evaluate } from './conditions.js'; import type { SystemNotice, SystemNoticeDTO } from './types.js'; function getCurrentAppVersion(): string { - const fromEnv = semver.valid(process.env.APP_VERSION ?? ''); + const fromEnv = semver.valid(readEnv().app.appVersion ?? ''); if (fromEnv) return fromEnv; try { const pkg = require('../../package.json') as { version?: string }; diff --git a/server/src/websocket.ts b/server/src/websocket.ts index b28d0b623..0e500b1a4 100644 --- a/server/src/websocket.ts +++ b/server/src/websocket.ts @@ -1,4 +1,5 @@ import { WebSocketServer, WebSocket } from 'ws'; +import { readEnv } from './app-config'; import { db, canAccessTrip } from './db/database'; import { consumeEphemeralTokenWithMeta } from './services/ephemeralTokens'; import { emitPluginEvent, pluginEventMeta } from './plugin-event-sink'; @@ -31,9 +32,7 @@ const socketMsgCounts = new WeakMap o.trim()) - : null; + const allowedOrigins = readEnv().http.wsOrigins; wss = new WebSocketServer({ server,