refactor(server): non-DI islands read env via app-config

This commit is contained in:
jubnl
2026-07-25 15:58:02 +02:00
parent 7f795a2199
commit fb9abdb76c
11 changed files with 64 additions and 71 deletions
+5 -4
View File
@@ -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);
+3 -2
View File
@@ -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
+7 -5
View File
@@ -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('');
+3 -1
View File
@@ -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 {
+4 -3
View File
@@ -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';
+14 -12
View File
@@ -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();
+5 -20
View File
@@ -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';
+8 -8
View File
@@ -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;
+11 -12
View File
@@ -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 () => {
+2 -1
View File
@@ -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 };
+2 -3
View File
@@ -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<NomadWebSocket, { count: number; windowStart
/** Attaches a WebSocket server with JWT auth, room-based trip channels, and heartbeat keep-alive. */
function setupWebSocket(server: http.Server): void {
const allowedOrigins = process.env.ALLOWED_ORIGINS
? process.env.ALLOWED_ORIGINS.split(',').map(o => o.trim())
: null;
const allowedOrigins = readEnv().http.wsOrigins;
wss = new WebSocketServer({
server,