From e5f9d8cbe853ca9e502dfa99b7f52df2ccf76465 Mon Sep 17 00:00:00 2001 From: weishu Date: Thu, 25 Dec 2025 12:21:10 +0800 Subject: [PATCH] feat: implement server configuration persistence to ~/.hapi/settings.json Configuration now loads with priority: environment variable > settings.json > default value. When values are read from environment variables and not present in settings.json, they are automatically saved for future use. This eliminates the need to repeatedly set environment variables. - New serverSettings.ts module handles loading/saving with persistence logic - Async createConfiguration() factory for proper initialization ordering - Configuration sources tracked and displayed in startup logs - Exported Settings interface and read/write functions from cliApiToken.ts - Updated index.ts to display configuration sources in log output --- server/src/configuration.ts | 198 ++++++++++++++++++++-------------- server/src/index.ts | 52 +++++---- server/src/serverSettings.ts | 196 +++++++++++++++++++++++++++++++++ server/src/web/cliApiToken.ts | 12 ++- 4 files changed, 354 insertions(+), 104 deletions(-) create mode 100644 server/src/serverSettings.ts diff --git a/server/src/configuration.ts b/server/src/configuration.ts index 6a8fdf03..2c2aa5be 100644 --- a/server/src/configuration.ts +++ b/server/src/configuration.ts @@ -1,15 +1,37 @@ /** * Configuration for hapi-server (Direct Connect) * + * Configuration is loaded with priority: environment variable > settings.json > default + * When values are read from environment variables and not present in settings.json, + * they are automatically saved for future use. + * * Optional environment variables: * - CLI_API_TOKEN: Shared secret for hapi CLI authentication (auto-generated if not set) * - TELEGRAM_BOT_TOKEN: Telegram Bot API token from @BotFather * - ALLOWED_CHAT_IDS: Comma-separated list of allowed Telegram chat IDs + * - WEBAPP_PORT: Port for Mini App HTTP server (default: 3006) + * - WEBAPP_URL: Public URL for Telegram Mini App + * - CORS_ORIGINS: Comma-separated CORS origins + * - HAPI_HOME: Data directory (default: ~/.hapi) + * - DB_PATH: SQLite database path (default: {HAPI_HOME}/hapi.db) */ import { existsSync, mkdirSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' +import { loadServerSettings, type ServerSettings, type ServerSettingsResult } from './serverSettings' +import { getOrCreateCliApiToken } from './web/cliApiToken' + +export type ConfigSource = 'env' | 'file' | 'default' + +export interface ConfigSources { + telegramBotToken: ConfigSource + allowedChatIds: ConfigSource + webappPort: ConfigSource + webappUrl: ConfigSource + corsOrigins: ConfigSource + cliApiToken: 'env' | 'file' | 'generated' +} class Configuration { /** Telegram Bot API token */ @@ -24,8 +46,11 @@ class Configuration { /** CLI auth token (shared secret) */ public cliApiToken: string - /** Source of CLI API token ('pending' | 'env' | 'file' | 'generated') */ - public cliApiTokenSource: string + /** Source of CLI API token */ + public cliApiTokenSource: 'env' | 'file' | 'generated' | '' + + /** Whether CLI API token was newly generated (for first-run display) */ + public cliApiTokenIsNew: boolean /** Path to settings.json file */ public readonly settingsFile: string @@ -45,87 +70,37 @@ class Configuration { /** Allowed CORS origins for Mini App + Socket.IO (comma-separated env override) */ public readonly corsOrigins: string[] - constructor() { - // Optional: Telegram Bot Token - const botToken = process.env.TELEGRAM_BOT_TOKEN - this.telegramBotToken = botToken ?? null + /** Sources of each configuration value */ + public readonly sources: ConfigSources + + /** Private constructor - use createConfiguration() instead */ + private constructor( + dataDir: string, + dbPath: string, + serverSettings: ServerSettings, + sources: ServerSettingsResult['sources'] + ) { + this.dataDir = dataDir + this.dbPath = dbPath + this.settingsFile = join(dataDir, 'settings.json') + + // Apply server settings + this.telegramBotToken = serverSettings.telegramBotToken this.telegramEnabled = Boolean(this.telegramBotToken) + this.allowedChatIds = serverSettings.allowedChatIds + this.webappPort = serverSettings.webappPort + this.miniAppUrl = serverSettings.webappUrl + this.corsOrigins = serverSettings.corsOrigins - // Optional: Allowed Chat IDs - const chatIdsStr = process.env.ALLOWED_CHAT_IDS - this.allowedChatIds = chatIdsStr - ? chatIdsStr - .split(',') - .map(id => parseInt(id.trim(), 10)) - .filter(id => !isNaN(id)) - : [] - - // CLI API token - will be set later by getOrCreateCliApiToken() + // CLI API token - will be set by _setCliApiToken() before create() returns this.cliApiToken = '' - this.cliApiTokenSource = 'pending' + this.cliApiTokenSource = '' + this.cliApiTokenIsNew = false - // Mini App web server configuration - const webappPortRaw = process.env.WEBAPP_PORT - const parsedWebappPort = webappPortRaw ? parseInt(webappPortRaw, 10) : 3006 - if (!Number.isFinite(parsedWebappPort) || parsedWebappPort <= 0) { - throw new Error('WEBAPP_PORT must be a valid port number') - } - this.webappPort = parsedWebappPort - - // For production, Telegram requires HTTPS for Mini Apps. - // This URL is what Telegram clients will open when pressing the WebApp button. - this.miniAppUrl = process.env.WEBAPP_URL || `http://localhost:${this.webappPort}` - - // CORS origin allowlist (Mini App + Socket.IO browser clients). - // - Defaults to the Mini App's origin (derived from WEBAPP_URL). - // - If set to "*", allows all origins (not recommended for internet-exposed deployments). - const corsOriginsRaw = process.env.CORS_ORIGINS - if (corsOriginsRaw) { - const entries = corsOriginsRaw - .split(',') - .map((origin) => origin.trim()) - .filter(Boolean) - - if (entries.includes('*')) { - this.corsOrigins = ['*'] - } else { - const normalized: string[] = [] - for (const entry of entries) { - try { - normalized.push(new URL(entry).origin) - } catch { - // Keep raw value if it's already an origin-like string. - normalized.push(entry) - } - } - this.corsOrigins = normalized - } - } else { - try { - this.corsOrigins = [new URL(this.miniAppUrl).origin] - } catch { - this.corsOrigins = [] - } - } - - // Data directory - if (process.env.HAPI_HOME) { - const expandedPath = process.env.HAPI_HOME.replace(/^~/, homedir()) - this.dataDir = expandedPath - } else { - this.dataDir = join(homedir(), '.hapi') - } - - // DB path (defaults inside dataDir) - if (process.env.DB_PATH) { - const expandedPath = process.env.DB_PATH.replace(/^~/, homedir()) - this.dbPath = expandedPath - } else { - this.dbPath = join(this.dataDir, 'hapi.db') - } - - // Settings file path - this.settingsFile = join(this.dataDir, 'settings.json') + // Store sources for logging (cliApiToken will be set by _setCliApiToken) + this.sources = { + ...sources, + } as ConfigSources // Ensure data directory exists if (!existsSync(this.dataDir)) { @@ -133,24 +108,81 @@ class Configuration { } } + /** Create configuration asynchronously */ + static async create(): Promise { + // 1. Determine data directory (env only - not persisted) + const dataDir = process.env.HAPI_HOME + ? process.env.HAPI_HOME.replace(/^~/, homedir()) + : join(homedir(), '.hapi') + + // Ensure data directory exists before loading settings + if (!existsSync(dataDir)) { + mkdirSync(dataDir, { recursive: true }) + } + + // 2. Determine DB path (env only - not persisted) + const dbPath = process.env.DB_PATH + ? process.env.DB_PATH.replace(/^~/, homedir()) + : join(dataDir, 'hapi.db') + + // 3. Load server settings (with persistence) + const settingsResult = await loadServerSettings(dataDir) + + if (settingsResult.savedToFile) { + console.log(`[Server] Configuration saved to ${join(dataDir, 'settings.json')}`) + } + + // 4. Create configuration instance + const config = new Configuration( + dataDir, + dbPath, + settingsResult.settings, + settingsResult.sources + ) + + // 5. Load CLI API token + const tokenResult = await getOrCreateCliApiToken(dataDir) + config._setCliApiToken(tokenResult.token, tokenResult.source, tokenResult.isNew) + + return config + } + /** Check if a chat ID is allowed */ isChatIdAllowed(chatId: number): boolean { return this.allowedChatIds.includes(chatId) } - /** Set CLI API token (called after async initialization) */ - _setCliApiToken(token: string, source: string): void { + /** Set CLI API token (called during async initialization) */ + _setCliApiToken(token: string, source: 'env' | 'file' | 'generated', isNew: boolean): void { this.cliApiToken = token this.cliApiTokenSource = source + this.cliApiTokenIsNew = isNew + ;(this.sources as { cliApiToken: string }).cliApiToken = source } } -// Lazy initialization to allow configuration to fail gracefully +// Singleton instance (set by createConfiguration) let _configuration: Configuration | null = null +/** + * Create and initialize configuration asynchronously. + * Must be called once at startup before getConfiguration() can be used. + */ +export async function createConfiguration(): Promise { + if (_configuration) { + return _configuration + } + _configuration = await Configuration.create() + return _configuration +} + +/** + * Get the initialized configuration. + * Throws if createConfiguration() has not been called yet. + */ export function getConfiguration(): Configuration { if (!_configuration) { - _configuration = new Configuration() + throw new Error('Configuration not initialized. Call createConfiguration() first.') } return _configuration } diff --git a/server/src/index.ts b/server/src/index.ts index e06a5dac..3d8a2c35 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -8,18 +8,31 @@ * - Optional Telegram bot for notifications and Mini App entrypoint */ -import { getConfiguration } from './configuration' +import { createConfiguration, type ConfigSource } from './configuration' import { Store } from './store' import { SyncEngine, type SyncEvent } from './sync/syncEngine' import { HappyBot } from './telegram/bot' import { startWebServer } from './web/server' import { getOrCreateJwtSecret } from './web/jwtSecret' -import { getOrCreateCliApiToken } from './web/cliApiToken' import { createSocketServer } from './socket/server' import { SSEManager } from './sse/sseManager' import type { Server as BunServer } from 'bun' import type { WebSocketData } from '@socket.io/bun-engine' +/** Format config source for logging */ +function formatSource(source: ConfigSource | 'generated'): string { + switch (source) { + case 'env': + return 'environment' + case 'file': + return 'settings.json' + case 'default': + return 'default' + case 'generated': + return 'generated' + } +} + let syncEngine: SyncEngine | null = null let happyBot: HappyBot | null = null let webServer: BunServer | null = null @@ -28,37 +41,40 @@ let sseManager: SSEManager | null = null async function main() { console.log('HAPI Server starting...') - // Load configuration - const config = getConfiguration() + // Load configuration (async - loads from env/file with persistence) + const config = await createConfiguration() - // Initialize CLI API token - const tokenResult = await getOrCreateCliApiToken(config.dataDir) - config._setCliApiToken(tokenResult.token, tokenResult.source) - - // Display token information - if (tokenResult.isNew) { + // Display CLI API token information + if (config.cliApiTokenIsNew) { console.log('') console.log('='.repeat(70)) console.log(' NEW CLI_API_TOKEN GENERATED') console.log('='.repeat(70)) console.log('') - console.log(` Token: ${tokenResult.token}`) + console.log(` Token: ${config.cliApiToken}`) console.log('') - console.log(` Saved to: ${tokenResult.filePath}`) + console.log(` Saved to: ${config.settingsFile}`) console.log('') console.log('='.repeat(70)) console.log('') } else { - console.log(`[Server] CLI_API_TOKEN: loaded from ${tokenResult.source}`) + console.log(`[Server] CLI_API_TOKEN: loaded from ${formatSource(config.sources.cliApiToken)}`) } - console.log(`[Server] Mini App: ${config.miniAppUrl} (port ${config.webappPort})`) + // Display other configuration sources + console.log(`[Server] WEBAPP_PORT: ${config.webappPort} (${formatSource(config.sources.webappPort)})`) + console.log(`[Server] WEBAPP_URL: ${config.miniAppUrl} (${formatSource(config.sources.webappUrl)})`) + if (!config.telegramEnabled) { - console.log('[Server] Telegram: disabled (missing TELEGRAM_BOT_TOKEN)') - } else if (config.allowedChatIds.length === 0) { - console.log('[Server] Telegram: enabled (allowlist empty; /start shows chat ID)') + console.log('[Server] Telegram: disabled (no TELEGRAM_BOT_TOKEN)') } else { - console.log(`[Server] Telegram: enabled (chat IDs: ${config.allowedChatIds.join(', ')})`) + const tokenSource = formatSource(config.sources.telegramBotToken) + if (config.allowedChatIds.length === 0) { + console.log(`[Server] Telegram: enabled (${tokenSource}), allowlist empty - /start shows chat ID`) + } else { + const idsSource = formatSource(config.sources.allowedChatIds) + console.log(`[Server] Telegram: enabled (${tokenSource}), chat IDs: ${config.allowedChatIds.join(', ')} (${idsSource})`) + } } const store = new Store(config.dbPath) diff --git a/server/src/serverSettings.ts b/server/src/serverSettings.ts new file mode 100644 index 00000000..a0fb3227 --- /dev/null +++ b/server/src/serverSettings.ts @@ -0,0 +1,196 @@ +/** + * Server Settings Management + * + * Handles loading and persistence of server configuration. + * Priority: environment variable > settings.json > default value + * + * When a value is loaded from environment variable and not present in settings.json, + * it will be saved to settings.json for future use. + */ + +import { join } from 'node:path' +import { readSettings, writeSettings, type Settings } from './web/cliApiToken' + +export interface ServerSettings { + telegramBotToken: string | null + allowedChatIds: number[] + webappPort: number + webappUrl: string + corsOrigins: string[] +} + +export interface ServerSettingsResult { + settings: ServerSettings + sources: { + telegramBotToken: 'env' | 'file' | 'default' + allowedChatIds: 'env' | 'file' | 'default' + webappPort: 'env' | 'file' | 'default' + webappUrl: 'env' | 'file' | 'default' + corsOrigins: 'env' | 'file' | 'default' + } + savedToFile: boolean +} + +/** + * Parse comma-separated chat IDs from string + */ +function parseChatIds(str: string): number[] { + return str + .split(',') + .map(id => parseInt(id.trim(), 10)) + .filter(id => !isNaN(id)) +} + +/** + * Parse and normalize CORS origins + */ +function parseCorsOrigins(str: string): string[] { + const entries = str + .split(',') + .map(origin => origin.trim()) + .filter(Boolean) + + if (entries.includes('*')) { + return ['*'] + } + + const normalized: string[] = [] + for (const entry of entries) { + try { + normalized.push(new URL(entry).origin) + } catch { + // Keep raw value if it's already an origin-like string + normalized.push(entry) + } + } + return normalized +} + +/** + * Derive CORS origins from webapp URL + */ +function deriveCorsOrigins(webappUrl: string): string[] { + try { + return [new URL(webappUrl).origin] + } catch { + return [] + } +} + +/** + * Load server settings with priority: env > file > default + * Saves new env values to file when not already present + */ +export async function loadServerSettings(dataDir: string): Promise { + const settingsFile = join(dataDir, 'settings.json') + const settings = await readSettings(settingsFile) + + // If settings file exists but couldn't be parsed, fail fast + if (settings === null) { + throw new Error( + `Cannot read ${settingsFile}. Please fix or remove the file and restart.` + ) + } + + let needsSave = false + const sources: ServerSettingsResult['sources'] = { + telegramBotToken: 'default', + allowedChatIds: 'default', + webappPort: 'default', + webappUrl: 'default', + corsOrigins: 'default', + } + + // telegramBotToken: env > file > null + let telegramBotToken: string | null = null + if (process.env.TELEGRAM_BOT_TOKEN) { + telegramBotToken = process.env.TELEGRAM_BOT_TOKEN + sources.telegramBotToken = 'env' + if (settings.telegramBotToken === undefined) { + settings.telegramBotToken = telegramBotToken + needsSave = true + } + } else if (settings.telegramBotToken !== undefined) { + telegramBotToken = settings.telegramBotToken + sources.telegramBotToken = 'file' + } + + // allowedChatIds: env > file > [] + let allowedChatIds: number[] = [] + if (process.env.ALLOWED_CHAT_IDS) { + allowedChatIds = parseChatIds(process.env.ALLOWED_CHAT_IDS) + sources.allowedChatIds = 'env' + if (settings.allowedChatIds === undefined) { + settings.allowedChatIds = allowedChatIds + needsSave = true + } + } else if (settings.allowedChatIds !== undefined) { + allowedChatIds = settings.allowedChatIds + sources.allowedChatIds = 'file' + } + + // webappPort: env > file > 3006 + let webappPort = 3006 + if (process.env.WEBAPP_PORT) { + const parsed = parseInt(process.env.WEBAPP_PORT, 10) + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error('WEBAPP_PORT must be a valid port number') + } + webappPort = parsed + sources.webappPort = 'env' + if (settings.webappPort === undefined) { + settings.webappPort = webappPort + needsSave = true + } + } else if (settings.webappPort !== undefined) { + webappPort = settings.webappPort + sources.webappPort = 'file' + } + + // webappUrl: env > file > http://localhost:{port} + let webappUrl = `http://localhost:${webappPort}` + if (process.env.WEBAPP_URL) { + webappUrl = process.env.WEBAPP_URL + sources.webappUrl = 'env' + if (settings.webappUrl === undefined) { + settings.webappUrl = webappUrl + needsSave = true + } + } else if (settings.webappUrl !== undefined) { + webappUrl = settings.webappUrl + sources.webappUrl = 'file' + } + + // corsOrigins: env > file > derived from webappUrl + let corsOrigins: string[] + if (process.env.CORS_ORIGINS) { + corsOrigins = parseCorsOrigins(process.env.CORS_ORIGINS) + sources.corsOrigins = 'env' + if (settings.corsOrigins === undefined) { + settings.corsOrigins = corsOrigins + needsSave = true + } + } else if (settings.corsOrigins !== undefined) { + corsOrigins = settings.corsOrigins + sources.corsOrigins = 'file' + } else { + corsOrigins = deriveCorsOrigins(webappUrl) + } + + // Save settings if any new values were added + if (needsSave) { + await writeSettings(settingsFile, settings) + } + + return { + settings: { + telegramBotToken, + allowedChatIds, + webappPort, + webappUrl, + corsOrigins, + }, + sources, + savedToFile: needsSave, + } +} diff --git a/server/src/web/cliApiToken.ts b/server/src/web/cliApiToken.ts index ccb2f072..e5302f5e 100644 --- a/server/src/web/cliApiToken.ts +++ b/server/src/web/cliApiToken.ts @@ -10,12 +10,18 @@ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises' import { randomBytes } from 'node:crypto' import { dirname, join } from 'node:path' -interface Settings { +export interface Settings { onboardingCompleted?: boolean machineId?: string machineIdConfirmedByServer?: boolean daemonAutoStartWhenRunningHappy?: boolean cliApiToken?: string + // Server configuration (persisted from environment variables) + telegramBotToken?: string + allowedChatIds?: number[] + webappPort?: number + webappUrl?: string + corsOrigins?: string[] } export interface CliApiTokenResult { @@ -53,7 +59,7 @@ function isWeakToken(token: string): boolean { * Read settings from file, preserving all existing fields. * Returns null if file exists but cannot be parsed (to avoid data loss). */ -async function readSettings(settingsFile: string): Promise { +export async function readSettings(settingsFile: string): Promise { if (!existsSync(settingsFile)) { return {} } @@ -70,7 +76,7 @@ async function readSettings(settingsFile: string): Promise { /** * Write settings to file atomically (temp file + rename) */ -async function writeSettings(settingsFile: string, settings: Settings): Promise { +export async function writeSettings(settingsFile: string, settings: Settings): Promise { const dir = dirname(settingsFile) if (!existsSync(dir)) { await mkdir(dir, { recursive: true, mode: 0o700 })