#!/usr/bin/env node // ═══════════════════════════════════════════════════════════════════════════════════════════════ // PURPOSE // Retrieval-only HTTP bridge over the AI index. Lets a client that cannot see Varaverk's // filesystem — Open-WebUI in its own container — search the corpus and receive grounding // chunks as JSON. // // OPERATIONAL MODEL // Started by start_ai_server.sh, which is the only thing that knows the port and secret. // Lives outside Unraid's nginx exactly as webhook_listener.js does, because the caller is a // container with no WebGUI session and nginx applies auth_request to everything it serves. // The shared secret in the query string is therefore the only gate. // // Returns chunks, never answers. Open-WebUI already has a model loaded and is going to // generate from whatever this returns; generating here as well would double the latency and // burn a second model load for a result the caller discards. ai_query.sh keeps the // generation path for CLI use — this endpoint deliberately does not. // // Calls search() in-process rather than shelling out to ai_query.sh. A shell round trip per // request would fork bash, re-source three conf files and re-open the index every time, for // a call that is otherwise a single embed plus a vector scan. // // DESIGN PRINCIPLES // One endpoint, one verb. // GET /search. No index management, no conf access, no generation. The surface an // unauthenticated port exposes should be the smallest thing that does the job. // // The caller chooses breadth, within bounds. // k, kind and section are accepted because the client knows what kind of question it is // asking. All are clamped or validated here — the caller is trusted to express intent, // not to be correct. // // Failures answer in the same shape as successes. // Every response is JSON with an ok flag. A tool calling this has no way to render an // HTML error page, and a caller that cannot parse the failure reports nothing at all. // // OPERATIONAL SAFEGUARDS // The secret is compared at fixed length before anything else runs. // No index is opened and no embedding is requested until the key matches, so an // unauthenticated caller cannot make this process do work. Compared with // timingSafeEqual on equal-length buffers — a plain !== leaks length and position // through response timing on a port that is, by design, reachable without a session. // // Read-only by construction. // The index is opened by search() for reading and nothing here writes to it, to conf, // or to the filesystem. The worst a valid key can do is read documentation that is // already in a git repository. // // Bounded work per request. // k is clamped to 1..25 and the query to 2000 characters, so no single call can pull the // whole index into memory or embed an unbounded string. // // Bad input is refused, not guessed. // kind is checked against the five real values; an unknown one is rejected rather than // passed to SQL where it would match zero rows and read as "the index has no answer" — // the most misleading failure this system can produce. // // Binds where it is told, and says so. // The bind address comes from the launcher. It is reachable from the docker bridge by // necessity; that is why the secret exists and why the surface is one read-only verb. // // CONFIGURATION // argv: // All supplied by start_ai_server.sh from AI_HTTP_PORT, AI_HTTP_SECRET, AI_INDEX_DB and // _OLLAMA_URL / _OLLAMA_EMBED_MODEL. // // RUNTIME MODES // GET /search?key=…&q=…[&k=8][&kind=readme][§ion=PURPOSE] // GET /health?key=… liveness only — no index access // ═══════════════════════════════════════════════════════════════════════════════════════════════ 'use strict'; const http = require('http'); const crypto = require('crypto'); const path = require('path'); const [, , port, secret, bind, db, ollamaUrl, embedModel] = process.argv; if (!port || !secret || !bind || !db || !ollamaUrl || !embedModel) { process.stderr.write('Usage: ai_serve.js \n'); process.exit(1); } const { search } = require(path.join(__dirname, 'lib', 'search.js')); const KINDS = new Set(['header', 'readme', 'manual', 'template', 'doc']); const MAX_Q = 2000; const SECRET_B = Buffer.from(secret); function send(res, code, obj) { const body = JSON.stringify(obj); res.writeHead(code, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body), 'Cache-Control': 'no-store', }); res.end(body); } // Equal-length compare first: timingSafeEqual throws on a length mismatch, and the length // itself is not worth leaking through an exception path on an unauthenticated port. function keyOk(given) { if (typeof given !== 'string') return false; const g = Buffer.from(given); if (g.length !== SECRET_B.length) return false; return crypto.timingSafeEqual(g, SECRET_B); } const server = http.createServer(async (req, res) => { let url; try { url = new URL(req.url, 'http://localhost'); } catch { return send(res, 400, { ok: false, error: 'Bad request' }); } if (!keyOk(url.searchParams.get('key'))) { return send(res, 403, { ok: false, error: 'Forbidden' }); } if (url.pathname === '/health') { return send(res, 200, { ok: true, service: 'ai_serve', db }); } if (url.pathname !== '/search') { return send(res, 404, { ok: false, error: 'Not found' }); } const q = (url.searchParams.get('q') || '').trim(); if (!q) return send(res, 400, { ok: false, error: 'q is required' }); if (q.length > MAX_Q) { return send(res, 400, { ok: false, error: `q exceeds ${MAX_Q} characters` }); } const kind = url.searchParams.get('kind') || null; if (kind && !KINDS.has(kind)) { return send(res, 400, { ok: false, error: `unknown kind '${kind}' (expected: ${[...KINDS].join(', ')})`, }); } let k = parseInt(url.searchParams.get('k') || '8', 10); if (!Number.isFinite(k)) k = 8; k = Math.min(Math.max(k, 1), 25); try { const r = await search({ dbPath: db, url: ollamaUrl, embedModel, query: q, k, perFile: 3, section: url.searchParams.get('section') || null, kind, }); send(res, 200, { ok: true, query: q, intents: r.intents, scanned: r.scanned, results: r.results.map(x => ({ path: x.path, section: x.section, heading: x.heading, score: Number(x.score.toFixed(4)), content: x.content, })), }); } catch (e) { send(res, 500, { ok: false, error: `retrieval failed: ${e.message}` }); } }); server.on('error', (e) => { process.stderr.write(`ai_serve: ${e.message}\n`); process.exit(1); }); server.listen(parseInt(port, 10), bind, () => { process.stdout.write(`ai_serve listening on ${bind}:${port}\n`); });