mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
refactor(server): non-DI islands read env via app-config
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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 () => {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user