From 1ecb44faa8e69eb5df10bd76d9077346bedce9b2 Mon Sep 17 00:00:00 2001 From: ScriptSorcerer Date: Wed, 23 Sep 2026 16:50:29 +0200 Subject: [PATCH] Added Typescript and rewritten config.js into config.ts (use AI) --- .gitignore | 2 + eslint.config.js | 28 ++++- package.json | 12 +- public/tsconfig.json | 15 +++ src/config.js | 94 --------------- src/config.ts | 282 +++++++++++++++++++++++++++++++++++++++++++ tsconfig.json | 20 +++ 7 files changed, 353 insertions(+), 100 deletions(-) create mode 100644 public/tsconfig.json delete mode 100644 src/config.js create mode 100644 src/config.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore index eb1d08e..85022ea 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ node_modules/ data/uploads/ +package-lock.json +.gitignore \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js index 2690113..43999a5 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,7 +1,29 @@ import js from "@eslint/js"; +import jsdoc from "eslint-plugin-jsdoc"; +import { defineConfig, globalIgnores } from "eslint/config"; import globals from "globals"; -import { defineConfig } from "eslint/config"; export default defineConfig([ - { files: ["**/*.{js,mjs,cjs}"], plugins: { js }, extends: ["js/recommended"], languageOptions: { globals: globals.browser } }, -]); + // Ordner, die nicht gelintet werden sollen (an dein Projekt anpassen) + globalIgnores(["sessions/", "uploads/", "data/"]), + + // Server-Code (Node) + { + files: ["**/*.{js,mjs,cjs}"], + ignores: ["public/**"], + plugins: { js }, + extends: ["js/recommended"], + languageOptions: { globals: globals.node }, + }, + + // Frontend-Code (Browser) + { + files: ["public/**/*.js"], + plugins: { js }, + extends: ["js/recommended"], + languageOptions: { globals: globals.browser }, + }, + + // JSDoc-Regeln + jsdoc.configs["flat/recommended-typescript-flavor"], +]); \ No newline at end of file diff --git a/package.json b/package.json index 0851eaf..6baecea 100644 --- a/package.json +++ b/package.json @@ -6,10 +6,12 @@ "engines": { "node": ">=22" }, - "scripts": { + "scripts": { "start": "node server.js", "dev": "node --watch server.js", - "lint": "node scripts/lint.mjs", + "lint": "eslint .", + "typecheck": "tsc --noEmit && tsc --noEmit -p public", + "check": "npm run lint && npm run typecheck", "test": "node --test" }, "dependencies": { @@ -25,7 +27,11 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@types/node": "^26.6.2", "eslint": "^10.11.0", - "globals": "^17.12.0" + "eslint-plugin-jsdoc": "^64.5.4", + "globals": "^17.12.0", + "typescript": "^6.0.3", + "typescript-eslint": "^8.70.1" } } diff --git a/public/tsconfig.json b/public/tsconfig.json new file mode 100644 index 0000000..1589119 --- /dev/null +++ b/public/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "allowJs": true, + "checkJs": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "types": [] + }, + "include": ["**/*.js"] +} \ No newline at end of file diff --git a/src/config.js b/src/config.js deleted file mode 100644 index c970a8f..0000000 --- a/src/config.js +++ /dev/null @@ -1,94 +0,0 @@ -import path from "path"; - -const bool = (value, fallback = false) => { - if (value == null || value === "") return fallback; - return ["1", "true", "yes", "on"].includes(String(value).toLowerCase()); -}; - -const int = (value, fallback) => { - const parsed = Number.parseInt(value, 10); - return Number.isInteger(parsed) ? parsed : fallback; -}; - -const trustProxy = (value, fallback = false) => { - if (value == null || value === "") return fallback; - const normalized = String(value).toLowerCase(); - if (["1", "true", "yes", "on"].includes(normalized)) return true; - if (["0", "false", "no", "off"].includes(normalized)) return false; - const parsed = Number.parseInt(value, 10); - return Number.isInteger(parsed) ? parsed : value; -}; - -export function createConfig(overrides = {}) { - const rootDir = overrides.rootDir || process.cwd(); - const dataDir = overrides.dataDir || path.resolve(rootDir, "data"); - const sessionsDir = overrides.sessionsDir || path.resolve(rootDir, "sessions"); - const testMode = bool(process.env.TEST_MODE, process.env.NODE_ENV === "test"); - - const config = { - rootDir, - publicDir: path.resolve(rootDir, "public"), - dataDir, - uploadDir: process.env.UPLOAD_DIR || (process.env.NODE_ENV === "production" ? "/app/data/uploads" : path.resolve(dataDir, "uploads")), - uploadMaxSizeMb: Math.max(1, int(process.env.UPLOAD_MAX_SIZE_MB, 10)), - uploadMaxFiles: Math.max(1, int(process.env.UPLOAD_MAX_FILES, 5)), - uploadMaxPixels: Math.max(1, int(process.env.UPLOAD_MAX_PIXELS, 30000000)), - visionModel: process.env.VISION_MODEL || "gemma3:4b", - sessionsDir, - port: int(process.env.PORT, 3000), - dbPath: process.env.DB_PATH || path.resolve(dataDir, "app.db"), - sessionSecret: process.env.SESSION_SECRET || "change-me-session-secret", - cookieSecure: bool(process.env.COOKIE_SECURE, false), - allowInsecureCookies: bool(process.env.ALLOW_INSECURE_COOKIES, false), - trustProxy: trustProxy(process.env.TRUST_PROXY, false), - sessionMaxAgeMs: int(process.env.SESSION_MAX_AGE_MS, 7 * 24 * 60 * 60 * 1000), - registrationInviteCode: process.env.REGISTRATION_INVITE_CODE || "", - sameOriginProtectionEnabled: bool(process.env.SAME_ORIGIN_PROTECTION_ENABLED, true), - staticMaxAge: process.env.STATIC_MAX_AGE || (process.env.NODE_ENV === "production" ? "1h" : 0), - staticEtag: bool(process.env.STATIC_ETAG, true), - staticFallthrough: bool(process.env.STATIC_FALLTHROUGH, true), - eventLoopLagMonitorEnabled: bool(process.env.EVENT_LOOP_LAG_MONITOR_ENABLED, !testMode), - eventLoopLagWarnMs: int(process.env.EVENT_LOOP_LAG_WARN_MS, 50), - slowRequestLoggingEnabled: bool(process.env.SLOW_REQUEST_LOGGING_ENABLED, !testMode), - slowRequestWarnMs: int(process.env.SLOW_REQUEST_WARN_MS, 50), - structuredRequestLoggingEnabled: bool(process.env.STRUCTURED_REQUEST_LOGGING_ENABLED, !testMode), - visionDebugSha256Enabled: bool(process.env.VISION_DEBUG_SHA256_ENABLED, false), - chatLimit: int(process.env.CHAT_LIMIT, 10), - chatHistoryLimit: int(process.env.CHAT_HISTORY_LIMIT, 20), - chatSummaryUpdateEveryMessages: int(process.env.CHAT_SUMMARY_UPDATE_EVERY_MESSAGES, 8), - chatSummaryMaxChars: int(process.env.CHAT_SUMMARY_MAX_CHARS, 2200), - chatMaxRetries: int(process.env.CHAT_MAX_RETRIES, 5), - modelApiBaseUrl: process.env.MODEL_API_BASE_URL || "https://ai.krishd.ch/api", - modelRequestYTimeoutMs: int(process.env.MODEL_REQUEST_TIMEOUT_MS, 180000), - modelConnectTimeoutMs: Math.max(1, int(process.env.MODEL_CONNECT_TIMEOUT_MS, 30000)), - modelInactivityTimeoutMs: Math.max(1, int(process.env.MODEL_INACTIVITY_TIMEOUT_MS, int(process.env.MODEL_REQUEST_TIMEOUT_MS, 180000))), - modelMaxGenerationMs: Math.max(1, int(process.env.MODEL_MAX_GENERATION_MS, 600000)), - ollamaKeepAlive: process.env.OLLAMA_KEEP_ALIVE || "30m", - searxngUrl: process.env.SEARXNG_URL || "", - webSearchTimeoutMs: Math.max(1, int(process.env.WEB_SEARCH_TIMEOUT_MS, 8000)), - modelListTimeoutMs: int(process.env.MODEL_LIST_TIMEOUT_MS, 4000), - modelUnloadAfterMs: int(process.env.MODEL_UNLOAD_AFTER_MS, 30 * 1000), - modelsCacheTtlMs: int(process.env.MODELS_CACHE_TTL_MS, 5 * 60 * 1000), - authRateLimitWindowMs: int(process.env.AUTH_RATE_LIMIT_WINDOW_MS, 15 * 60 * 1000), - authRateLimitMaxPerIp: int(process.env.AUTH_RATE_LIMIT_MAX_PER_IP, 20), - authRateLimitMaxPerAccount: int(process.env.AUTH_RATE_LIMIT_MAX_PER_ACCOUNT, 10), - chatRateLimitWindowMs: int(process.env.CHAT_RATE_LIMIT_WINDOW_MS, 60 * 1000), - chatRateLimitMaxPerIp: int(process.env.CHAT_RATE_LIMIT_MAX_PER_IP, 60), - chatRateLimitMaxPerAccount: int(process.env.CHAT_RATE_LIMIT_MAX_PER_ACCOUNT, 30), - rateLimitCleanupIntervalMs: int(process.env.RATE_LIMIT_CLEANUP_INTERVAL_MS, 60 * 1000), - testMode, - dropLegacyChats: bool(process.env.DROP_LEGACY_CHATS, true) - }; - - const resolved = {...config, ...overrides}; - if (process.env.NODE_ENV === "production" && !resolved.testMode) { - if (resolved.sessionSecret === "change-me-session-secret") { - throw new Error("SESSION_SECRET must be set in production."); - } - if (!resolved.cookieSecure && !resolved.allowInsecureCookies) { - throw new Error("COOKIE_SECURE must be true in production unless ALLOW_INSECURE_COOKIES=true is set."); - } - } - - return resolved; -} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..d271645 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,282 @@ +import path from "path"; + +/** + * Mögliche Werte für die Express-Einstellung `trust proxy`. + * + * - `boolean`: allen oder keinem Proxy vertrauen + * - `number`: Anzahl der vertrauenswürdigen Proxy-Hops + * - `string`: z. B. `"loopback"` oder eine IP-Adresse/Subnetz + * + * Todo: + * Fallback soll soll Fehler loggen. + */ +export type TrustProxyValue = boolean | number | string; + +/** + * Vollständige Anwendungskonfiguration. + * + * Wird von {@link createConfig} aus Umgebungsvariablen und optionalen + * Overrides zusammengesetzt. + */ +export interface Config { + /** Wurzelverzeichnis der Anwendung. */ + rootDir: string; + /** Verzeichnis für statische Dateien. */ + publicDir: string; + /** Verzeichnis für persistente Daten. */ + dataDir: string; + /** Zielverzeichnis für hochgeladene Dateien. */ + uploadDir: string; + /** Maximale Upload-Größe pro Datei in MB (mindestens 1). */ + uploadMaxSizeMb: number; + /** Maximale Anzahl Dateien pro Upload (mindestens 1). */ + uploadMaxFiles: number; + /** Maximale Pixelanzahl eines hochgeladenen Bildes (mindestens 1). */ + uploadMaxPixels: number; + /** Name des Vision-Modells. */ + visionModel: string; + /** Verzeichnis für Session-Dateien. */ + sessionsDir: string; + /** Port, auf dem der Server lauscht. */ + port: number; + /** Pfad zur SQLite-Datenbank. */ + dbPath: string; + /** Secret zum Signieren von Session-Cookies. */ + sessionSecret: string; + /** Ob Cookies nur über HTTPS gesendet werden. */ + cookieSecure: boolean; + /** Erlaubt unsichere Cookies in Produktion (nicht empfohlen). */ + allowInsecureCookies: boolean; + /** Wert für die Express-Einstellung `trust proxy`. */ + trustProxy: TrustProxyValue; + /** Maximale Session-Dauer in Millisekunden. */ + sessionMaxAgeMs: number; + /** Einladungscode für die Registrierung (leer = kein Code nötig). */ + registrationInviteCode: string; + /** Aktiviert den Same-Origin-Schutz. */ + sameOriginProtectionEnabled: boolean; + /** Cache-Dauer für statische Dateien (z. B. `"1h"` oder `0`). */ + staticMaxAge: string | number; + /** Ob ETags für statische Dateien gesendet werden. */ + staticEtag: boolean; + /** Ob nicht gefundene statische Dateien an die nächste Middleware weitergereicht werden. */ + staticFallthrough: boolean; + /** Aktiviert die Überwachung der Event-Loop-Verzögerung. */ + eventLoopLagMonitorEnabled: boolean; + /** Schwellwert für Event-Loop-Warnungen in Millisekunden. */ + eventLoopLagWarnMs: number; + /** Aktiviert das Logging langsamer Requests. */ + slowRequestLoggingEnabled: boolean; + /** Schwellwert für langsame Requests in Millisekunden. */ + slowRequestWarnMs: number; + /** Aktiviert strukturiertes Request-Logging. */ + structuredRequestLoggingEnabled: boolean; + /** Loggt SHA-256-Hashes von Vision-Eingaben zu Debug-Zwecken. */ + visionDebugSha256Enabled: boolean; + /** Maximale Anzahl Chats pro Benutzer. */ + chatLimit: number; + /** Anzahl Nachrichten, die als Verlauf ans Modell gesendet werden. */ + chatHistoryLimit: number; + /** Nach wie vielen Nachrichten die Chat-Zusammenfassung aktualisiert wird. */ + chatSummaryUpdateEveryMessages: number; + /** Maximale Länge der Chat-Zusammenfassung in Zeichen. */ + chatSummaryMaxChars: number; + /** Maximale Anzahl Wiederholungsversuche bei Modellanfragen. */ + chatMaxRetries: number; + /** Basis-URL der Modell-API. */ + modelApiBaseUrl: string; + /** Gesamt-Timeout einer Modellanfrage in Millisekunden. */ + modelRequestTimeoutMs: number; + /** Timeout für den Verbindungsaufbau zur Modell-API in Millisekunden. */ + modelConnectTimeoutMs: number; + /** Maximale Zeit ohne neue Daten vom Modell in Millisekunden. */ + modelInactivityTimeoutMs: number; + /** Maximale Generierungsdauer in Millisekunden. */ + modelMaxGenerationMs: number; + /** Wie lange Ollama ein Modell im Speicher hält (z. B. `"30m"`). */ + ollamaKeepAlive: string; + /** URL der SearXNG-Instanz (leer = Websuche deaktiviert). */ + searxngUrl: string; + /** Timeout für Websuchen in Millisekunden. */ + webSearchTimeoutMs: number; + /** Timeout beim Abrufen der Modellliste in Millisekunden. */ + modelListTimeoutMs: number; + /** Nach wie vielen Millisekunden Inaktivität ein Modell entladen wird. */ + modelUnloadAfterMs: number; + /** Cache-Dauer der Modellliste in Millisekunden. */ + modelsCacheTtlMs: number; + /** Zeitfenster für das Auth-Rate-Limit in Millisekunden. */ + authRateLimitWindowMs: number; + /** Maximale Auth-Versuche pro IP im Zeitfenster. */ + authRateLimitMaxPerIp: number; + /** Maximale Auth-Versuche pro Account im Zeitfenster. */ + authRateLimitMaxPerAccount: number; + /** Zeitfenster für das Chat-Rate-Limit in Millisekunden. */ + chatRateLimitWindowMs: number; + /** Maximale Chat-Anfragen pro IP im Zeitfenster. */ + chatRateLimitMaxPerIp: number; + /** Maximale Chat-Anfragen pro Account im Zeitfenster. */ + chatRateLimitMaxPerAccount: number; + /** Intervall zum Aufräumen der Rate-Limit-Daten in Millisekunden. */ + rateLimitCleanupIntervalMs: number; + /** Ob die Anwendung im Testmodus läuft. */ + testMode: boolean; + /** Ob Chats im alten Format beim Start gelöscht werden. */ + dropLegacyChats: boolean; +} + +/** + * Wandelt einen Wert (typischerweise eine Umgebungsvariable) in einen Boolean um. + * + * Als `true` gelten `"1"`, `"true"`, `"yes"` und `"on"` (Groß-/Kleinschreibung egal). + * Alle anderen Werte ergeben `false`. + * + * @param value - Der umzuwandelnde Wert. + * @param fallback - Rückgabewert, wenn `value` `null`, `undefined` oder leer ist. + * @returns Der interpretierte Boolean. + * + * @example + * bool("YES"); // true + * bool("0"); // false + * bool(undefined, true); // true (Fallback) + */ +const bool = (value: unknown, fallback: boolean = false): boolean => { + if (value == null || value === "") return fallback; + return ["1", "true", "yes", "on"].includes(String(value).toLowerCase()); +}; + +/** + * Wandelt einen Wert (typischerweise eine Umgebungsvariable) in eine Ganzzahl um. + * + * @param value - Der umzuwandelnde Wert. + * @param fallback - Rückgabewert, wenn `value` keine gültige Ganzzahl ergibt. + * @returns Die geparste Ganzzahl oder `fallback`. + * + * @example + * int("3000", 80); // 3000 + * int("abc", 80); // 80 + * int(undefined, 80); // 80 + */ +const int = (value: unknown, fallback: number): number => { + const parsed = Number.parseInt(String(value), 10); + return Number.isInteger(parsed) ? parsed : fallback; +}; + +/** + * Wandelt einen Wert in eine gültige Einstellung für Express' `trust proxy` um. + * + * Reihenfolge der Auswertung: + * 1. leer/nicht gesetzt → `fallback` + * 2. `"1"`, `"true"`, `"yes"`, `"on"` → `true` + * 3. `"0"`, `"false"`, `"no"`, `"off"` → `false` + * 4. Ganzzahl → Anzahl vertrauenswürdiger Proxys + * 5. sonst → Originalwert als String (z. B. `"loopback"` oder eine IP) + * + * @param value - Der umzuwandelnde Wert. + * @param fallback - Rückgabewert, wenn `value` `null`, `undefined` oder leer ist. + * @returns Boolean, Zahl oder String für `trust proxy`. + * + * @example + * trustProxy("on"); // true + * trustProxy("2"); // 2 + * trustProxy("loopback"); // "loopback" + */ +const trustProxy = (value: unknown, fallback: boolean = false): TrustProxyValue => { + if (value == null || value === "") return fallback; + const normalized = String(value).toLowerCase(); + if (["1", "true", "yes", "on"].includes(normalized)) return true; + if (["0", "false", "no", "off"].includes(normalized)) return false; + const parsed = Number.parseInt(normalized, 10); + return Number.isInteger(parsed) ? parsed : String(value); +}; + +/** + * Erstellt die Anwendungskonfiguration aus Umgebungsvariablen. + * + * Werte aus `overrides` haben Vorrang vor Umgebungsvariablen und Standardwerten. + * In Produktion (ausser im Testmodus) wird zusätzlich geprüft, ob + * sicherheitsrelevante Einstellungen korrekt gesetzt sind. + * + * @param overrides - Einzelne Konfigurationswerte, die überschrieben werden sollen. + * @returns Die vollständige, aufgelöste Konfiguration. + * @throws {Error} Wenn in Produktion kein `SESSION_SECRET` gesetzt ist. + * @throws {Error} Wenn in Produktion Cookies unsicher sind, ohne dass + * `ALLOW_INSECURE_COOKIES` gesetzt ist. + * + * @example + * const config = createConfig({ port: 4000, testMode: true }); + */ +export function createConfig(overrides: Partial = {}): Config { + const rootDir = overrides.rootDir || process.cwd(); + const dataDir = overrides.dataDir || path.resolve(rootDir, "data"); + const sessionsDir = overrides.sessionsDir || path.resolve(rootDir, "sessions"); + const testMode = bool(process.env.TEST_MODE, process.env.NODE_ENV === "test"); + + const config: Config = { + rootDir, + publicDir: path.resolve(rootDir, "public"), + dataDir, + uploadDir: process.env.UPLOAD_DIR || (process.env.NODE_ENV === "production" ? "/app/data/uploads" : path.resolve(dataDir, "uploads")), + uploadMaxSizeMb: Math.max(1, int(process.env.UPLOAD_MAX_SIZE_MB, 10)), + uploadMaxFiles: Math.max(1, int(process.env.UPLOAD_MAX_FILES, 5)), + uploadMaxPixels: Math.max(1, int(process.env.UPLOAD_MAX_PIXELS, 30000000)), + visionModel: process.env.VISION_MODEL || "gemma3:4b", + sessionsDir, + port: int(process.env.PORT, 3000), + dbPath: process.env.DB_PATH || path.resolve(dataDir, "app.db"), + sessionSecret: process.env.SESSION_SECRET || "change-me-session-secret", + cookieSecure: bool(process.env.COOKIE_SECURE, false), + allowInsecureCookies: bool(process.env.ALLOW_INSECURE_COOKIES, false), + trustProxy: trustProxy(process.env.TRUST_PROXY, false), + sessionMaxAgeMs: int(process.env.SESSION_MAX_AGE_MS, 7 * 24 * 60 * 60 * 1000), + registrationInviteCode: process.env.REGISTRATION_INVITE_CODE || "", + sameOriginProtectionEnabled: bool(process.env.SAME_ORIGIN_PROTECTION_ENABLED, true), + staticMaxAge: process.env.STATIC_MAX_AGE || (process.env.NODE_ENV === "production" ? "1h" : 0), + staticEtag: bool(process.env.STATIC_ETAG, true), + staticFallthrough: bool(process.env.STATIC_FALLTHROUGH, true), + eventLoopLagMonitorEnabled: bool(process.env.EVENT_LOOP_LAG_MONITOR_ENABLED, !testMode), + eventLoopLagWarnMs: int(process.env.EVENT_LOOP_LAG_WARN_MS, 50), + slowRequestLoggingEnabled: bool(process.env.SLOW_REQUEST_LOGGING_ENABLED, !testMode), + slowRequestWarnMs: int(process.env.SLOW_REQUEST_WARN_MS, 50), + structuredRequestLoggingEnabled: bool(process.env.STRUCTURED_REQUEST_LOGGING_ENABLED, !testMode), + visionDebugSha256Enabled: bool(process.env.VISION_DEBUG_SHA256_ENABLED, false), + chatLimit: int(process.env.CHAT_LIMIT, 10), + chatHistoryLimit: int(process.env.CHAT_HISTORY_LIMIT, 20), + chatSummaryUpdateEveryMessages: int(process.env.CHAT_SUMMARY_UPDATE_EVERY_MESSAGES, 8), + chatSummaryMaxChars: int(process.env.CHAT_SUMMARY_MAX_CHARS, 2200), + chatMaxRetries: int(process.env.CHAT_MAX_RETRIES, 5), + modelApiBaseUrl: process.env.MODEL_API_BASE_URL || "https://ai.krishd.ch/api", + modelRequestTimeoutMs: int(process.env.MODEL_REQUEST_TIMEOUT_MS, 180000), + modelConnectTimeoutMs: Math.max(1, int(process.env.MODEL_CONNECT_TIMEOUT_MS, 30000)), + modelInactivityTimeoutMs: Math.max(1, int(process.env.MODEL_INACTIVITY_TIMEOUT_MS, int(process.env.MODEL_REQUEST_TIMEOUT_MS, 180000))), + modelMaxGenerationMs: Math.max(1, int(process.env.MODEL_MAX_GENERATION_MS, 600000)), + ollamaKeepAlive: process.env.OLLAMA_KEEP_ALIVE || "30m", + searxngUrl: process.env.SEARXNG_URL || "", + webSearchTimeoutMs: Math.max(1, int(process.env.WEB_SEARCH_TIMEOUT_MS, 8000)), + modelListTimeoutMs: int(process.env.MODEL_LIST_TIMEOUT_MS, 4000), + modelUnloadAfterMs: int(process.env.MODEL_UNLOAD_AFTER_MS, 30 * 1000), + modelsCacheTtlMs: int(process.env.MODELS_CACHE_TTL_MS, 5 * 60 * 1000), + authRateLimitWindowMs: int(process.env.AUTH_RATE_LIMIT_WINDOW_MS, 15 * 60 * 1000), + authRateLimitMaxPerIp: int(process.env.AUTH_RATE_LIMIT_MAX_PER_IP, 20), + authRateLimitMaxPerAccount: int(process.env.AUTH_RATE_LIMIT_MAX_PER_ACCOUNT, 10), + chatRateLimitWindowMs: int(process.env.CHAT_RATE_LIMIT_WINDOW_MS, 60 * 1000), + chatRateLimitMaxPerIp: int(process.env.CHAT_RATE_LIMIT_MAX_PER_IP, 60), + chatRateLimitMaxPerAccount: int(process.env.CHAT_RATE_LIMIT_MAX_PER_ACCOUNT, 30), + rateLimitCleanupIntervalMs: int(process.env.RATE_LIMIT_CLEANUP_INTERVAL_MS, 60 * 1000), + testMode, + dropLegacyChats: bool(process.env.DROP_LEGACY_CHATS, true) + }; + + const resolved: Config = { ...config, ...overrides }; + + if (process.env.NODE_ENV === "production" && !resolved.testMode) { + if (resolved.sessionSecret === "change-me-session-secret") { + throw new Error("SESSION_SECRET must be set in production."); + } + if (!resolved.cookieSecure && !resolved.allowInsecureCookies) { + throw new Error("COOKIE_SECURE must be true in production unless ALLOW_INSECURE_COOKIES=true is set."); + } + } + + return resolved; +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..f89d1f8 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "allowJs": true, + "checkJs": false, + "noEmit": true, + "strict": true, + "allowImportingTsExtensions": true, + "erasableSyntaxOnly": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "types": ["node"] + }, + "include": ["**/*.js", "src/*.ts"], + "exclude": ["node_modules", "public"] +} \ No newline at end of file