From a2481a9bc7a489bde2edaaf4e4bca0725d192d38 Mon Sep 17 00:00:00 2001 From: Kieran Klukas Date: Wed, 3 Jun 2026 19:53:55 -0400 Subject: [PATCH] feat: proper config valdiation --- src/config.ts | 75 +++++++++++++++++++++++++++++++++++++++++++ src/handlers/index.ts | 5 +-- src/index.ts | 15 ++++++--- src/slackWrapper.ts | 25 +++++++-------- 4 files changed, 101 insertions(+), 19 deletions(-) create mode 100644 src/config.ts diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..b379007 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,75 @@ +/** + * Centralized configuration. Parses all environment variables once at startup, + * validates required values, and exports a frozen typed config object. + */ + +export interface AppConfig { + readonly port: number; + readonly databasePath: string; + readonly development: boolean; + readonly bearerToken: string | null; + readonly slack: { + readonly signingSecret: string; + readonly botToken: string; + readonly maxConcurrent: number; + readonly minTimeMs: number; + readonly requestTimeoutMs: number; + }; +} + +function parsePositiveInt(value: string | undefined, fallback: number): number { + if (!value) return fallback; + const n = Number.parseInt(value, 10); + return Number.isFinite(n) && n > 0 ? n : fallback; +} + +function loadConfig(): AppConfig { + const errors: string[] = []; + + const signingSecret = process.env.SLACK_SIGNING_SECRET || ""; + const botToken = process.env.SLACK_BOT_TOKEN || process.env.SLACK_TOKEN || ""; + + if (!signingSecret) { + errors.push("SLACK_SIGNING_SECRET is required"); + } + if (!botToken) { + errors.push("SLACK_BOT_TOKEN (or SLACK_TOKEN) is required"); + } + + const portRaw = process.env.PORT; + let port = 3000; + if (portRaw) { + port = Number.parseInt(portRaw, 10); + if (!Number.isFinite(port) || port <= 0 || port > 65535) { + errors.push(`PORT must be a valid port number (1-65535), got "${portRaw}"`); + port = 3000; + } + } + + const bearerToken = process.env.BEARER_TOKEN || null; + if (!bearerToken) { + console.warn("BEARER_TOKEN is not set. Admin endpoints (/reset, /users/:id/purge) will return 500."); + } + + if (errors.length > 0) { + throw new Error(`Configuration errors:\n - ${errors.join("\n - ")}`); + } + + const config: AppConfig = { + port, + databasePath: process.env.DATABASE_PATH ?? "./data/cachet.db", + development: process.env.NODE_ENV === "dev", + bearerToken, + slack: { + signingSecret, + botToken, + maxConcurrent: parsePositiveInt(process.env.SLACK_MAX_CONCURRENT, 3), + minTimeMs: parsePositiveInt(process.env.SLACK_MIN_TIME_MS, 200), + requestTimeoutMs: parsePositiveInt(process.env.SLACK_REQUEST_TIMEOUT_MS, 5000), + }, + }; + + return Object.freeze(config); +} + +export const config = loadConfig(); diff --git a/src/handlers/index.ts b/src/handlers/index.ts index 51685d2..cb5c66f 100644 --- a/src/handlers/index.ts +++ b/src/handlers/index.ts @@ -3,6 +3,7 @@ */ import type { SlackCache } from "../cache"; +import { config } from "../config"; import type { RouteHandlerWithAnalytics } from "../lib/analytics-wrapper"; /** @@ -113,7 +114,7 @@ export function createHandlers(cache: SlackCache) { request, recordAnalytics, ) => { - const configuredToken = process.env.BEARER_TOKEN; + const configuredToken = config.bearerToken; if (!configuredToken) { console.error("BEARER_TOKEN is not configured"); recordAnalytics(500); @@ -189,7 +190,7 @@ export function createHandlers(cache: SlackCache) { request, recordAnalytics, ) => { - const configuredToken = process.env.BEARER_TOKEN; + const configuredToken = config.bearerToken; if (!configuredToken) { console.error("BEARER_TOKEN is not configured"); recordAnalytics(500); diff --git a/src/index.ts b/src/index.ts index b783a09..690276a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ import { serve } from "bun"; +import { config } from "./config"; import { getEmojiUrl } from "../utils/emojiHelper"; import { SlackCache } from "./cache"; import dashboard from "./dashboard.html"; @@ -8,9 +9,15 @@ import { SlackWrapper } from "./slackWrapper"; import swagger from "./swagger.html"; // Initialize SlackWrapper and Cache -const slackApp = new SlackWrapper(); +const slackApp = new SlackWrapper({ + signingSecret: config.slack.signingSecret, + botToken: config.slack.botToken, + maxConcurrent: config.slack.maxConcurrent, + minTimeMs: config.slack.minTimeMs, + requestTimeoutMs: config.slack.requestTimeoutMs, +}); const cache = new SlackCache( - process.env.DATABASE_PATH ?? "./data/cachet.db", + config.databasePath, 25, async () => { console.log("Fetching emojis from Slack"); @@ -143,8 +150,8 @@ const allRoutes = { // Start the server const server = serve({ routes: allRoutes, - port: process.env.PORT ? parseInt(process.env.PORT, 10) : 3000, - development: process.env.NODE_ENV === "dev", + port: config.port, + development: config.development, }); console.log(`🚀 Server running on http://localhost:${server.port}`); diff --git a/src/slackWrapper.ts b/src/slackWrapper.ts index 134692c..88ad1da 100644 --- a/src/slackWrapper.ts +++ b/src/slackWrapper.ts @@ -14,6 +14,12 @@ interface SlackConfig { signingSecret?: string; /** Slack bot user OAuth token */ botToken?: string; + /** Max concurrent requests (default: 3) */ + maxConcurrent?: number; + /** Min time between requests in ms (default: 200) */ + minTimeMs?: number; + /** Request timeout in ms (default: 5000) */ + requestTimeoutMs?: number; } /** @@ -31,25 +37,18 @@ class SlackWrapper { * @throws Error if required credentials are missing */ constructor(config?: SlackConfig) { - this.signingSecret = - config?.signingSecret || process.env.SLACK_SIGNING_SECRET || ""; - this.botToken = - config?.botToken || - process.env.SLACK_BOT_TOKEN || - process.env.SLACK_TOKEN || - ""; - - // Configure rate limiting - defaults are conservative to respect Slack API limits - const maxConcurrent = Number(process.env.SLACK_MAX_CONCURRENT ?? 3); - const minTime = Number(process.env.SLACK_MIN_TIME_MS ?? 200); // ~5 requests per second + this.signingSecret = config?.signingSecret || ""; + this.botToken = config?.botToken || ""; + + const maxConcurrent = config?.maxConcurrent ?? 3; + const minTime = config?.minTimeMs ?? 200; this.limiter = new Bottleneck({ maxConcurrent: Number.isFinite(maxConcurrent) && maxConcurrent > 0 ? maxConcurrent : 3, minTime: Number.isFinite(minTime) && minTime > 0 ? minTime : 200, }); - // Request timeout in ms (default 5 seconds) - const timeout = Number(process.env.SLACK_REQUEST_TIMEOUT_MS ?? 5000); + const timeout = config?.requestTimeoutMs ?? 5000; this.requestTimeout = Number.isFinite(timeout) && timeout > 0 ? timeout : 5000; -- 2.51.2