From 3b31f4256b4f46091cbc70cae31bb9ad73d81fcc Mon Sep 17 00:00:00 2001 From: jubnl Date: Sat, 25 Jul 2026 16:01:42 +0200 Subject: [PATCH] refactor(server): move language/session parsing into app-config, config.ts becomes keys module --- server/src/app-config/derive.ts | 9 ++- server/src/config.ts | 77 ++++----------------- server/tests/unit/app-config/derive.test.ts | 7 ++ 3 files changed, 30 insertions(+), 63 deletions(-) diff --git a/server/src/app-config/derive.ts b/server/src/app-config/derive.ts index ae7733cc6..2c6bae182 100644 --- a/server/src/app-config/derive.ts +++ b/server/src/app-config/derive.ts @@ -14,6 +14,7 @@ * call, which is what keeps the runtime env-mutation semantics the test suite * depends on. Zod validation runs once at boot (env.schema.ts), never here. */ +import { SUPPORTED_LANGUAGE_CODES } from '@trek/shared'; import { csvList, csvListFiltered, @@ -46,10 +47,16 @@ export function deriveApp(raw: RawEnv) { appUrl: raw.APP_URL, tz: raw.TZ, logLevel: raw.LOG_LEVEL, - defaultLanguage: raw.DEFAULT_LANGUAGE, + /** Resolved: lowercased, validated against the supported set, falls back to 'en' (src/config.ts semantics). */ + defaultLanguage: resolveDefaultLanguage(raw.DEFAULT_LANGUAGE), }; } +function resolveDefaultLanguage(raw: string | undefined): string { + const lang = raw?.toLowerCase() || 'en'; + return SUPPORTED_LANGUAGE_CODES.includes(lang) ? lang : 'en'; +} + export function deriveHttp(raw: RawEnv) { return { allowedOriginsRaw: raw.ALLOWED_ORIGINS, diff --git a/server/src/config.ts b/server/src/config.ts index 8c7ab68f1..a58090ed9 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -1,8 +1,7 @@ -import { SUPPORTED_LANGUAGE_CODES as SUPPORTED_LANG_CODES } from '@trek/shared'; - import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; +import { readEnv } from './app-config'; const dataDir = path.resolve(__dirname, '../data'); const jwtSecretFile = path.join(dataDir, '.jwt_secret'); @@ -107,70 +106,24 @@ export function updateJwtSecret(newSecret: string): void { JWT_SECRET = newSecret; } -// DEFAULT_LANGUAGE sets the language shown on the login page before the user -// selects one. Only applies when the user has no saved language preference. -const rawDefaultLang = process.env.DEFAULT_LANGUAGE?.toLowerCase() || 'en'; -if (!SUPPORTED_LANG_CODES.includes(rawDefaultLang)) { - console.warn( - `DEFAULT_LANGUAGE="${rawDefaultLang}" is not supported. Falling back to "en". Supported: ${SUPPORTED_LANG_CODES.join(', ')}`, - ); -} -export const DEFAULT_LANGUAGE = SUPPORTED_LANG_CODES.includes(rawDefaultLang) ? rawDefaultLang : 'en'; +// DEFAULT_LANGUAGE / SESSION_DURATION* parsing moved into src/app-config +// (env.schema.ts validates fail-fast at boot; derive.ts resolves/falls back). +// Re-exported here — frozen at import, exactly like before — so the many +// importers of this module (mcp, middleware, services, e2e harness) are +// untouched. This module now owns only key material (above). +const sessionEnv = readEnv().session; -// SESSION_DURATION controls how long a TREK session (the `trek_session` JWT -// cookie) stays valid before re-login is required. Accepts ms-style strings: -// '1h', '12h', '7d', '30d', '90d', etc. It applies to BOTH the JWT `exp` claim -// and the cookie `maxAge`, so the two never drift apart. Invalid values warn at -// startup and fall back to the default. Does not affect the short-lived MFA -// challenge token or MCP OAuth tokens — those keep their own TTL. -const DEFAULT_SESSION_DURATION = '24h'; -const DURATION_UNITS_MS: Record = { - ms: 1, - s: 1000, - m: 60_000, - h: 3_600_000, - d: 86_400_000, - w: 604_800_000, - y: 31_557_600_000, -}; -function parseDurationMs(value: string): number | null { - const m = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w|y)?$/i.exec(value.trim()); - if (!m) return null; - const n = parseFloat(m[1]); - if (!Number.isFinite(n) || n <= 0) return null; - return n * DURATION_UNITS_MS[(m[2] || 'ms').toLowerCase()]; -} -const rawSessionDuration = process.env.SESSION_DURATION?.trim() || DEFAULT_SESSION_DURATION; -const parsedSessionMs = parseDurationMs(rawSessionDuration); -if (parsedSessionMs == null) { - console.warn( - `SESSION_DURATION="${rawSessionDuration}" is not a valid duration (use e.g. 1h, 7d, 30d). Falling back to "${DEFAULT_SESSION_DURATION}".`, - ); -} +/** Default login-page language, validated against the supported set ('en' fallback). */ +export const DEFAULT_LANGUAGE = readEnv().app.defaultLanguage; /** Human-readable session length actually in effect (for logs/diagnostics). */ -export const SESSION_DURATION = parsedSessionMs == null ? DEFAULT_SESSION_DURATION : rawSessionDuration; +export const SESSION_DURATION = sessionEnv.duration; /** Session length in milliseconds — used for the cookie `maxAge`. */ -export const SESSION_DURATION_MS = parsedSessionMs ?? parseDurationMs(DEFAULT_SESSION_DURATION)!; +export const SESSION_DURATION_MS = sessionEnv.durationMs; /** Session length in seconds — passed to `jwt.sign({ expiresIn })` (number = seconds). */ -export const SESSION_DURATION_SECONDS = Math.floor(SESSION_DURATION_MS / 1000); - -// SESSION_DURATION_REMEMBER is the session length used when the user ticks -// "Remember me" on the login form: a longer-lived JWT `exp` claim plus a -// persistent `trek_session` cookie `maxAge`. An unticked login keeps -// SESSION_DURATION and a browser-session cookie (no `maxAge`). Same ms-style -// format and fallback behavior as SESSION_DURATION. -const DEFAULT_SESSION_DURATION_REMEMBER = '30d'; -const rawRememberDuration = process.env.SESSION_DURATION_REMEMBER?.trim() || DEFAULT_SESSION_DURATION_REMEMBER; -const parsedRememberMs = parseDurationMs(rawRememberDuration); -if (parsedRememberMs == null) { - console.warn( - `SESSION_DURATION_REMEMBER="${rawRememberDuration}" is not a valid duration (use e.g. 7d, 30d, 90d). Falling back to "${DEFAULT_SESSION_DURATION_REMEMBER}".`, - ); -} +export const SESSION_DURATION_SECONDS = sessionEnv.durationSeconds; /** Human-readable "remember me" session length actually in effect (for logs/diagnostics). */ -export const SESSION_DURATION_REMEMBER = - parsedRememberMs == null ? DEFAULT_SESSION_DURATION_REMEMBER : rawRememberDuration; +export const SESSION_DURATION_REMEMBER = sessionEnv.durationRemember; /** "Remember me" session length in milliseconds — used for the persistent cookie `maxAge`. */ -export const SESSION_DURATION_REMEMBER_MS = parsedRememberMs ?? parseDurationMs(DEFAULT_SESSION_DURATION_REMEMBER)!; +export const SESSION_DURATION_REMEMBER_MS = sessionEnv.durationRememberMs; /** "Remember me" session length in seconds — passed to `jwt.sign({ expiresIn })`. */ -export const SESSION_DURATION_REMEMBER_SECONDS = Math.floor(SESSION_DURATION_REMEMBER_MS / 1000); +export const SESSION_DURATION_REMEMBER_SECONDS = sessionEnv.durationRememberSeconds; diff --git a/server/tests/unit/app-config/derive.test.ts b/server/tests/unit/app-config/derive.test.ts index fde8b92ce..66371031b 100644 --- a/server/tests/unit/app-config/derive.test.ts +++ b/server/tests/unit/app-config/derive.test.ts @@ -38,6 +38,13 @@ describe('deriveApp', () => { expect(deriveApp({}).appVersion).toBeUndefined(); expect(deriveApp({ APP_URL: 'https://x.example/' }).appUrl).toBe('https://x.example/'); }); + + it('DEFAULT_LANGUAGE resolves: lowercased, validated, en fallback (config.ts semantics)', () => { + expect(deriveApp({ DEFAULT_LANGUAGE: 'EN' }).defaultLanguage).toBe('en'); + expect(deriveApp({ DEFAULT_LANGUAGE: 'de' }).defaultLanguage).toBe('de'); + expect(deriveApp({ DEFAULT_LANGUAGE: 'klingon' }).defaultLanguage).toBe('en'); + expect(deriveApp({}).defaultLanguage).toBe('en'); + }); }); describe('deriveHttp', () => {