/** * TDAI Gateway — HTTP server for the Hermes sidecar. * * Exposes TDAI Core capabilities as HTTP endpoints: * GET /health — Health check * POST /recall — Memory recall (prefetch) * POST /capture — Conversation capture (sync_turn) * POST /search/memories — L1 memory search * POST /search/conversations — L0 conversation search * POST /session/end — Session end + flush * POST /seed — Batch seed historical conversations (L0 → L1) * * Built with Node.js native `http` module — no Express/Fastify dependency. * Designed to run as a managed sidecar alongside Hermes. */ import http from "node:http"; import { URL } from "node:url"; import { timingSafeEqual } from "node:crypto"; import { TdaiCore } from "../core/tdai-core.js"; import { StandaloneHostAdapter } from "../adapters/standalone/host-adapter.js"; import { loadGatewayConfig } from "./config.js"; import type { GatewayConfig } from "./config.js"; import { initDataDirectories } from "../utils/pipeline-factory.js"; import { SessionFilter } from "../utils/session-filter.js"; import type { HealthResponse, RecallRequest, RecallResponse, CaptureRequest, CaptureResponse, MemorySearchRequest, MemorySearchResponse, ConversationSearchRequest, ConversationSearchResponse, SessionEndRequest, SessionEndResponse, SeedRequest, SeedResponse, GatewayErrorResponse, } from "./types.js"; import type { Logger } from "../core/types.js"; import { validateAndNormalizeRaw, fillTimestamps, SeedValidationError } from "../core/seed/input.js"; import { executeSeed } from "../core/seed/seed-runtime.js"; import type { SeedProgress } from "../core/seed/types.js"; const TAG = "[tdai-gateway]"; const VERSION = "0.1.0"; // ============================ // Console logger (for standalone gateway — no OpenClaw logger available) // ============================ function createConsoleLogger(): Logger { return { debug: (msg: string) => console.debug(`${TAG} ${msg}`), info: (msg: string) => console.info(`${TAG} ${msg}`), warn: (msg: string) => console.warn(`${TAG} ${msg}`), error: (msg: string) => console.error(`${TAG} ${msg}`), }; } // ============================ // Request body parser // ============================ async function parseJsonBody(req: http.IncomingMessage): Promise { return new Promise((resolve, reject) => { const chunks: Buffer[] = []; req.on("data", (chunk: Buffer) => chunks.push(chunk)); req.on("end", () => { try { const body = Buffer.concat(chunks).toString("utf-8"); resolve(JSON.parse(body) as T); } catch (err) { reject(new Error("Invalid JSON body")); } }); req.on("error", reject); }); } function sendJson(res: http.ServerResponse, status: number, body: unknown): void { const json = JSON.stringify(body); res.writeHead(status, { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(json), }); res.end(json); } function sendError(res: http.ServerResponse, status: number, message: string): void { sendJson(res, status, { error: message } satisfies GatewayErrorResponse); } /** * Constant-time string equality for secrets. * * Returns `false` on any length mismatch (without comparing bytes), and uses * `crypto.timingSafeEqual` for the equal-length case so that an attacker * probing the API key cannot use response timing to learn a prefix match. */ function safeEqual(a: string, b: string): boolean { const ab = Buffer.from(a, "utf-8"); const bb = Buffer.from(b, "utf-8"); if (ab.length !== bb.length) return false; return timingSafeEqual(ab, bb); } // ============================ // Gateway Server // ============================ export class TdaiGateway { private config: GatewayConfig; private logger: Logger; private core: TdaiCore; private server: http.Server | null = null; private startTime = Date.now(); constructor(configOverrides?: Partial) { this.config = loadGatewayConfig(configOverrides); this.logger = createConsoleLogger(); // Create host adapter const adapter = new StandaloneHostAdapter({ dataDir: this.config.data.baseDir, llmConfig: this.config.llm, logger: this.logger, platform: "gateway", }); // Create core this.core = new TdaiCore({ hostAdapter: adapter, config: this.config.memory, sessionFilter: new SessionFilter(this.config.memory.capture.excludeAgents), }); } /** * Start the Gateway HTTP server. */ async start(): Promise { // Initialize data directories initDataDirectories(this.config.data.baseDir); // Initialize core await this.core.initialize(); // Create HTTP server this.server = http.createServer((req, res) => this.handleRequest(req, res)); const { port, host } = this.config.server; await new Promise((resolve, reject) => { this.server!.listen(port, host, () => { this.startTime = Date.now(); this.logger.info(`Gateway listening on http://${host}:${port}`); this.logSecurityPosture(); resolve(); }); this.server!.on("error", reject); }); } /** * Emit a one-shot security posture summary at startup. * * Goals: * 1. Make the "auth disabled" state highly visible to anyone reading logs * (this is the documented default, but operators must know it before * they expose the port). * 2. Loudly warn when the gateway is bound to anything other than the * loopback interface without an API key — that exact combination is * what the security audit flagged as a real exposure. * 3. Never log the key itself. */ private logSecurityPosture(): void { const { host, apiKey, corsOrigins } = this.config.server; const authOn = !!apiKey; const loopback = host === "127.0.0.1" || host === "localhost" || host === "::1"; this.logger.info( `Security posture: auth=${authOn ? "ENABLED (Bearer)" : "disabled"} ` + `host=${host} cors=${corsOrigins.length === 0 ? "no-headers" : corsOrigins.includes("*") ? "wildcard(*)" : `allowlist(${corsOrigins.length})`}` ); if (!authOn) { this.logger.warn( "TDAI_GATEWAY_API_KEY is NOT set — all routes except GET /health are " + "open to anyone who can reach this port. This is the legacy default. " + "Set TDAI_GATEWAY_API_KEY (or server.apiKey in tdai-gateway.yaml) and " + "pass `Authorization: Bearer ` from clients before exposing the " + "gateway beyond the loopback interface." ); } if (!loopback && !authOn) { this.logger.warn( `Gateway is bound to ${host} (non-loopback) WITHOUT an API key. ` + "Every /capture, /search/conversations, /recall, /seed call from the " + "network is currently unauthenticated. Bind to 127.0.0.1, or set " + "TDAI_GATEWAY_API_KEY, before continuing." ); } if (corsOrigins.includes("*")) { this.logger.warn( "CORS allow-list contains '*' — every browser origin can call this " + "gateway. Restrict server.corsOrigins to a concrete allow-list for any " + "non-local deployment." ); } } /** * Gracefully stop the Gateway. */ async stop(): Promise { this.logger.info("Shutting down gateway..."); if (this.server) { await new Promise((resolve) => { this.server!.close(() => resolve()); }); } await this.core.destroy(); this.logger.info("Gateway stopped"); } // ============================ // Request router // ============================ private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise { const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`); const method = req.method?.toUpperCase() ?? "GET"; const pathname = url.pathname; // Apply CORS headers based on configured allow-list (empty → no headers). this.applyCorsHeaders(req, res); if (method === "OPTIONS") { res.writeHead(204); res.end(); return; } try { // GET /health is always reachable without auth — operators and // orchestrators (k8s liveness, docker health-check) rely on it being // an unconditionally cheap probe. if (method === "GET" && pathname === "/health") { return this.handleHealth(res); } // All other routes go through the optional auth gate. When apiKey is // unset the gate is a no-op (preserves legacy open behaviour) — the // startup WARN in `logSecurityPosture` covers that case. if (!this.checkAuth(req, res)) return; switch (`${method} ${pathname}`) { case "POST /recall": return await this.handleRecall(req, res); case "POST /capture": return await this.handleCapture(req, res); case "POST /search/memories": return await this.handleSearchMemories(req, res); case "POST /search/conversations": return await this.handleSearchConversations(req, res); case "POST /session/end": return await this.handleSessionEnd(req, res); case "POST /seed": return await this.handleSeed(req, res); default: sendError(res, 404, `Not found: ${method} ${pathname}`); } } catch (err) { const msg = err instanceof Error ? err.message : String(err); this.logger.error(`Request error [${method} ${pathname}]: ${msg}`); sendError(res, 500, msg); } } // ============================ // Auth & CORS gates (opt-in, off by default) // ============================ /** * Verify the `Authorization: Bearer ` header against the configured * shared secret using a constant-time comparison. * * When `server.apiKey` is unset (`undefined`), this returns `true` without * inspecting the request — this is the documented default and matches the * pre-existing open behaviour. Operators are reminded of this at startup * via `logSecurityPosture`. * * Returns `false` (and writes 401) when the token is missing, malformed, or * does not match. Callers must short-circuit on `false`. */ private checkAuth(req: http.IncomingMessage, res: http.ServerResponse): boolean { const expected = this.config.server.apiKey; if (!expected) return true; // auth disabled — default behaviour const header = req.headers["authorization"]; if (typeof header !== "string" || !header.startsWith("Bearer ")) { sendError(res, 401, "Unauthorized: missing Bearer token"); return false; } const provided = header.slice("Bearer ".length).trim(); if (!provided || !safeEqual(provided, expected)) { sendError(res, 401, "Unauthorized: invalid token"); return false; } return true; } /** * Echo `Access-Control-Allow-Origin` (and friends) only for whitelisted * origins. With no list configured we emit no CORS headers at all, which * makes the browser refuse the cross-origin request as desired. * * The single-entry list `["*"]` opts back into permissive CORS (development * use only; the startup log flags this loudly). */ private applyCorsHeaders(req: http.IncomingMessage, res: http.ServerResponse): void { const allow = this.config.server.corsOrigins ?? []; if (allow.length === 0) return; // strict default — no headers if (allow.includes("*")) { // Wildcard — preserves the legacy permissive behaviour for callers that // opt in explicitly via config. Note: with wildcard we deliberately do // not echo back the request Origin and do not send `Vary: Origin`, // mirroring how the gateway behaved before this change. res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); return; } const requestOrigin = req.headers["origin"]; if (typeof requestOrigin !== "string" || !allow.includes(requestOrigin)) { // Origin not in allow-list — emit no CORS headers; browser will block. // Always set Vary so caches don't poison responses across origins. res.setHeader("Vary", "Origin"); return; } res.setHeader("Access-Control-Allow-Origin", requestOrigin); res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); res.setHeader("Vary", "Origin"); } // ============================ // Route handlers // ============================ private handleHealth(res: http.ServerResponse): void { const response: HealthResponse = { status: this.core.getVectorStore() ? "ok" : "degraded", version: VERSION, uptime: Math.floor((Date.now() - this.startTime) / 1000), stores: { vectorStore: !!this.core.getVectorStore(), embeddingService: !!this.core.getEmbeddingService(), }, }; sendJson(res, 200, response); } private async handleRecall(req: http.IncomingMessage, res: http.ServerResponse): Promise { const body = await parseJsonBody(req); if (!body.query || !body.session_key) { sendError(res, 400, "Missing required fields: query, session_key"); return; } const startMs = Date.now(); const result = await this.core.handleBeforeRecall(body.query, body.session_key); const elapsed = Date.now() - startMs; this.logger.info(`Recall completed in ${elapsed}ms: context=${(result.appendSystemContext?.length ?? 0)} chars`); const response: RecallResponse = { context: result.appendSystemContext ?? "", strategy: result.recallStrategy, memory_count: result.recalledL1Memories?.length ?? 0, }; sendJson(res, 200, response); } private async handleCapture(req: http.IncomingMessage, res: http.ServerResponse): Promise { const body = await parseJsonBody(req); if (!body.user_content || !body.assistant_content || !body.session_key) { sendError(res, 400, "Missing required fields: user_content, assistant_content, session_key"); return; } const startMs = Date.now(); const result = await this.core.handleTurnCommitted({ userText: body.user_content, assistantText: body.assistant_content, messages: body.messages ?? [ { role: "user", content: body.user_content }, { role: "assistant", content: body.assistant_content }, ], sessionKey: body.session_key, sessionId: body.session_id, }); const elapsed = Date.now() - startMs; this.logger.info(`Capture completed in ${elapsed}ms: l0=${result.l0RecordedCount}`); const response: CaptureResponse = { l0_recorded: result.l0RecordedCount, scheduler_notified: result.schedulerNotified, }; sendJson(res, 200, response); } private async handleSearchMemories(req: http.IncomingMessage, res: http.ServerResponse): Promise { const body = await parseJsonBody(req); if (!body.query) { sendError(res, 400, "Missing required field: query"); return; } const result = await this.core.searchMemories({ query: body.query, limit: body.limit, type: body.type, scene: body.scene, }); const response: MemorySearchResponse = { results: result.text, total: result.total, strategy: result.strategy, }; sendJson(res, 200, response); } private async handleSearchConversations(req: http.IncomingMessage, res: http.ServerResponse): Promise { const body = await parseJsonBody(req); if (!body.query) { sendError(res, 400, "Missing required field: query"); return; } const result = await this.core.searchConversations({ query: body.query, limit: body.limit, sessionKey: body.session_key, }); const response: ConversationSearchResponse = { results: result.text, total: result.total, }; sendJson(res, 200, response); } private async handleSessionEnd(req: http.IncomingMessage, res: http.ServerResponse): Promise { const body = await parseJsonBody(req); if (!body.session_key) { sendError(res, 400, "Missing required field: session_key"); return; } await this.core.handleSessionEnd(body.session_key); const response: SessionEndResponse = { flushed: true }; sendJson(res, 200, response); } private async handleSeed(req: http.IncomingMessage, res: http.ServerResponse): Promise { const body = await parseJsonBody(req); if (!body.data) { sendError(res, 400, "Missing required field: data"); return; } // Validate and normalize input (reuses seed CLI's validation layers 2-6) let input; try { input = validateAndNormalizeRaw(body.data, { sessionKey: body.session_key, strictRoundRole: body.strict_round_role, autoFillTimestamps: body.auto_fill_timestamps ?? true, }); } catch (err) { if (err instanceof SeedValidationError) { sendJson(res, 400, { error: err.message, validation_errors: err.errors, }); return; } throw err; } this.logger.info( `Seed request: ${input.sessions.length} session(s), ` + `${input.totalRounds} round(s), ${input.totalMessages} message(s)`, ); // Resolve output directory: use gateway's data dir with a timestamped subfolder const now = new Date(); const pad = (n: number) => String(n).padStart(2, "0"); const ts = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-` + `${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`; const outputDir = `${this.config.data.baseDir}/seed-${ts}`; // Merge config overrides if provided // Start with the base memory config + inject llm config from gateway settings const baseConfig = this.config.memory as unknown as Record; let pluginConfig: Record = { ...baseConfig, llm: { enabled: true, baseUrl: this.config.llm.baseUrl, apiKey: this.config.llm.apiKey, model: this.config.llm.model, maxTokens: this.config.llm.maxTokens, timeoutMs: this.config.llm.timeoutMs, }, }; if (body.config_override) { for (const key of Object.keys(body.config_override)) { const baseVal = pluginConfig[key]; const overVal = body.config_override[key]; if (baseVal && typeof baseVal === "object" && !Array.isArray(baseVal) && overVal && typeof overVal === "object" && !Array.isArray(overVal)) { pluginConfig[key] = { ...(baseVal as Record), ...(overVal as Record) }; } else { pluginConfig[key] = overVal; } } } // Execute seed pipeline (blocking — this may take minutes for large inputs) const summary = await executeSeed(input, { outputDir, openclawConfig: {}, pluginConfig, logger: this.logger as import("../utils/pipeline-factory.js").PipelineLogger, onProgress: (progress: SeedProgress) => { this.logger.debug?.( `Seed progress: [${progress.currentRound}/${progress.totalRounds}] ` + `session=${progress.sessionKey} stage=${progress.stage}`, ); }, }); this.logger.info( `Seed complete: sessions=${summary.sessionsProcessed}, rounds=${summary.roundsProcessed}, ` + `l0=${summary.l0RecordedCount}, duration=${(summary.durationMs / 1000).toFixed(1)}s`, ); const response: SeedResponse = { sessions_processed: summary.sessionsProcessed, rounds_processed: summary.roundsProcessed, messages_processed: summary.messagesProcessed, l0_recorded: summary.l0RecordedCount, duration_ms: summary.durationMs, output_dir: summary.outputDir, }; sendJson(res, 200, response); } } // ============================ // CLI entry point // ============================ /** * Start the gateway from the command line. * Usage: node --import tsx src/gateway/server.ts */ async function main(): Promise { const gateway = new TdaiGateway(); // Graceful shutdown const shutdown = async () => { await gateway.stop(); process.exit(0); }; process.on("SIGINT", shutdown); process.on("SIGTERM", shutdown); await gateway.start(); } // Auto-start when run directly const isMain = process.argv[1]?.endsWith("server.ts") || process.argv[1]?.endsWith("server.js"); if (isMain) { main().catch((err) => { console.error("Gateway startup failed:", err); process.exit(1); }); }