135 lines
4.4 KiB
TypeScript
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));
|
|
}
|