Files
zesdex/src/interfaces/api/state.ts
T

135 lines
4.4 KiB
TypeScript

/**
* 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<string, number[]>();
/** 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<string, string> | null {
const p = path.join(baseDir, "users.json");
try {
return JSON.parse(fs.readFileSync(p, "utf8")) as Record<string, string>;
} catch {
return null;
}
}
/** Persist the users map to `users.json` (pretty-printed). */
export function saveUsers(baseDir: string, users: Record<string, string>): void {
const p = path.join(baseDir, "users.json");
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, JSON.stringify(users, null, 2));
}