/** * Shared application state for the REST API server. * Mirrors `apps/interfaces/api/src/state.rs`. * * `ApiState` holds all service implementations wired to concrete * infrastructure adapters. Constructed once at startup (composition root) * and shared across all requests. */ import * as fs from "node:fs"; import * as path from "node:path"; import { SessionServiceImpl, ConversationServiceImpl, SettingsServiceImpl, MemoryServiceImpl, } from "@zesdex/application"; import { JsonSettingsRepository, JsonAppConfigRepository, JsonConversationRepository, MarkdownMemoryRepository, FileSystemSessionRepository, FileSystemSessionLockRepository, Argon2PasswordService, Hs256TokenService, LlmClient, } from "@zesdex/infrastructure"; /** * Sliding-window rate limiter for auth endpoints. * Mirrors `infrastructure::middleware::rate_limit::RateLimiter`. */ export class RateLimiter { private hits = new Map(); /** Check whether `key` is within `max` requests per `windowSecs`. Returns true if allowed. */ check(key: string, max: number, windowSecs: number): boolean { const now = Date.now(); const cutoff = now - windowSecs * 1000; const list = (this.hits.get(key) ?? []).filter((t) => t > cutoff); if (list.length >= max) { this.hits.set(key, list); return false; } list.push(now); this.hits.set(key, list); return true; } } /** Concrete session repository wired from infrastructure. */ const sessionRepo = new FileSystemSessionRepository(); const sessionLockRepo = new FileSystemSessionLockRepository(); const conversationRepo = new JsonConversationRepository(); const settingsRepo = new JsonSettingsRepository(); const appConfigRepo = new JsonAppConfigRepository(); const memoryRepo = new MarkdownMemoryRepository(); /** Rooted, long-lived shared API state. */ export interface ApiState { store_base_dir: string; jwt_secret: string; session_service: SessionServiceImpl; conversation_service: ConversationServiceImpl; settings_service: SettingsServiceImpl; memory_service: MemoryServiceImpl; password_service: Argon2PasswordService; token_service: Hs256TokenService; auth_rate_limiter: RateLimiter; /** Serializes read-modify-write of `users.json` (TOCTOU race guard). */ users_lock: { lock: () => () => void }; llm_client: LlmClient; } /** * A no-op lock that returns an unlock function. * Bun is single-threaded per process, so the JS event loop already * serialises synchronous read-modify-write of users.json — the lock is * retained for structural parity with the Rust mutex guard. */ function noopLock(): { lock: () => () => void } { return { lock: () => () => {} }; } /** Construct a new API state with all services wired to their defaults. */ export function newApiState( baseDir: string, jwtSecret: string, llmApiKey: string, llmModel: string, llmBaseUrl?: string, ): ApiState { const sessionsDir = path.join(baseDir, "sessions"); const memoryDir = path.join(baseDir, "memories"); const session_service = new SessionServiceImpl(sessionRepo, sessionLockRepo, baseDir); const conversation_service = new ConversationServiceImpl(conversationRepo, sessionsDir); const settings_service = new SettingsServiceImpl(settingsRepo, appConfigRepo, baseDir); const memory_service = new MemoryServiceImpl(memoryRepo, memoryDir); const token_service = new Hs256TokenService(jwtSecret); const llm = new LlmClient(llmApiKey, llmModel, llmBaseUrl); return { store_base_dir: baseDir, jwt_secret: jwtSecret, session_service, conversation_service, settings_service, memory_service, password_service: new Argon2PasswordService(), token_service, auth_rate_limiter: new RateLimiter(), users_lock: noopLock(), llm_client: llm, }; } /** Load the `users.json` map (username → password hash), or null if absent. */ export function loadUsers(baseDir: string): Record | null { const p = path.join(baseDir, "users.json"); try { return JSON.parse(fs.readFileSync(p, "utf8")) as Record; } catch { return null; } } /** Persist the users map to `users.json` (pretty-printed). */ export function saveUsers(baseDir: string, users: Record): void { const p = path.join(baseDir, "users.json"); fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, JSON.stringify(users, null, 2)); }