refactor(server): move language/session parsing into app-config, config.ts becomes keys module

This commit is contained in:
jubnl
2026-07-25 16:01:42 +02:00
parent fb9abdb76c
commit 3b31f4256b
3 changed files with 30 additions and 63 deletions
+8 -1
View File
@@ -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,
+15 -62
View File
@@ -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<string, number> = {
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;
@@ -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', () => {