#!/usr/bin/env node /** * pi-loom minimal HTTP service. * * Keeps LoomStore as the core runtime and adds only a thin tenant/auth boundary. */ import { createServer as createHttpServer, type IncomingMessage, type RequestListener, type Server as HttpServer, type ServerResponse, } from "node:http"; import { createServer as createHttpsServer, type Server as HttpsServer, type ServerOptions as HttpsOptions } from "node:https"; import { mkdirSync, readFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import type Database from "better-sqlite3"; import { handleLoomApply, handleLoomContext, handleLoomEvidence, handleLoomInsights, handleLoomProfile, handleLoomRecall, handleLoomReview, handleLoomSearch, handleLoomStatus, handleLoomStore, type ToolResult, } from "./handlers.js"; import { LoomStore, openDb } from "./store.js"; type Handler = (store: LoomStore, params: Record) => ToolResult | Promise; const TOOL_HANDLERS: Record = { loom_store: (store, params) => handleLoomStore(store, params, null), loom_recall: handleLoomRecall, loom_search: handleLoomSearch, loom_context: handleLoomContext, loom_review: handleLoomReview, loom_apply: handleLoomApply, loom_status: (store) => handleLoomStatus(store), loom_insights: handleLoomInsights, loom_profile: handleLoomProfile, loom_evidence: (store, params) => handleLoomEvidence(store, params, null), }; export interface LoomServiceOptions { dataDir?: string; tokens?: Record; allowAnonymous?: boolean; tls?: HttpsOptions; } interface TenantStore { dir: string; db: Database.Database; store: LoomStore; } interface AuthResult { ok: boolean; tenant?: string; status?: number; error?: string; } type LoomServiceServer = HttpServer | HttpsServer; export function createLoomHttpServer(options: LoomServiceOptions = {}): HttpServer { const runtime = createLoomServiceRuntime(options); const server = createHttpServer(runtime.handle); server.on("close", runtime.close); return server; } export function createLoomHttpsServer(options: LoomServiceOptions & { tls: HttpsOptions }): HttpsServer { const runtime = createLoomServiceRuntime(options); const server = createHttpsServer(options.tls, runtime.handle); server.on("close", runtime.close); return server; } function createLoomServiceRuntime(options: LoomServiceOptions = {}): { handle: RequestListener; close: () => void } { const dataDir = resolve(options.dataDir ?? process.env.PI_LOOM_DATA_DIR ?? process.env.PI_LOOM_DIR ?? ".pi-loom-service"); const tokens = options.tokens ?? readTokensFromEnv(); const allowAnonymous = options.allowAnonymous ?? process.env.PI_LOOM_SERVICE_ALLOW_ANON === "true"; const stores = new Map(); const getTenantStore = (tenant: string): TenantStore => { const safeTenant = sanitizeTenant(tenant); const cached = stores.get(safeTenant); if (cached) return cached; const dir = join(dataDir, safeTenant); mkdirSync(dir, { recursive: true }); const previousDir = process.env.PI_LOOM_DIR; process.env.PI_LOOM_DIR = dir; try { const db = openDb(); const store = new LoomStore(db); const tenantStore = { dir, db, store }; stores.set(safeTenant, tenantStore); return tenantStore; } finally { if (previousDir === undefined) delete process.env.PI_LOOM_DIR; else process.env.PI_LOOM_DIR = previousDir; } }; const handle: RequestListener = async (req, res) => { try { if (req.method === "GET" && req.url === "/health") { return sendJson(res, 200, { ok: true, service: "pi-loom", tools: Object.keys(TOOL_HANDLERS).length }); } if (req.method === "GET" && req.url === "/v1/tools") { const auth = authenticate(req, tokens, allowAnonymous); if (!auth.ok) return sendJson(res, auth.status ?? 401, { error: auth.error }); return sendJson(res, 200, { tools: Object.keys(TOOL_HANDLERS).sort() }); } if (req.method === "POST" && req.url?.startsWith("/v1/tools/")) { const auth = authenticate(req, tokens, allowAnonymous); if (!auth.ok || !auth.tenant) return sendJson(res, auth.status ?? 401, { error: auth.error }); const tool = decodeURIComponent(req.url.slice("/v1/tools/".length)); const handler = TOOL_HANDLERS[tool]; if (!handler) return sendJson(res, 404, { error: `unknown tool: ${tool}` }); const body = await readJson(req); const params = isRecord(body) ? body : {}; const tenantStore = getTenantStore(auth.tenant); const result = await handler(tenantStore.store, params); return sendJson(res, 200, { tenant: auth.tenant, result }); } if (req.method === "GET" && req.url === "/v1/status") { const auth = authenticate(req, tokens, allowAnonymous); if (!auth.ok || !auth.tenant) return sendJson(res, auth.status ?? 401, { error: auth.error }); const tenantStore = getTenantStore(auth.tenant); return sendJson(res, 200, { tenant: auth.tenant, data_dir: tenantStore.dir, stats: tenantStore.store.stats(), }); } return sendJson(res, 404, { error: "not found" }); } catch (err) { return sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) }); } }; const close = () => { for (const tenantStore of stores.values()) tenantStore.db.close(); stores.clear(); }; return { handle, close }; } function authenticate(req: IncomingMessage, tokens: Record, allowAnonymous: boolean): AuthResult { const token = readBearer(req.headers.authorization); if (token && tokens[token]) return { ok: true, tenant: sanitizeTenant(tokens[token]) }; if (token && !tokens[token]) return { ok: false, status: 403, error: "invalid token" }; if (allowAnonymous) return { ok: true, tenant: sanitizeTenant((req.headers["x-loom-tenant"] as string | undefined) ?? "default") }; return { ok: false, status: 401, error: "missing bearer token" }; } function readBearer(header: string | undefined): string | undefined { if (!header) return undefined; const match = header.match(/^Bearer\s+(.+)$/i); return match?.[1]?.trim(); } function readTokensFromEnv(): Record { if (process.env.PI_LOOM_SERVICE_TOKENS) { const parsed = JSON.parse(process.env.PI_LOOM_SERVICE_TOKENS) as unknown; if (!isStringRecord(parsed)) throw new Error("PI_LOOM_SERVICE_TOKENS must be a JSON object of token -> tenant"); return parsed; } if (process.env.PI_LOOM_SERVICE_TOKEN) return { [process.env.PI_LOOM_SERVICE_TOKEN]: "default" }; return {}; } function sanitizeTenant(tenant: string): string { if (!/^[a-zA-Z0-9._-]+$/.test(tenant)) throw new Error("invalid tenant id"); return tenant; } async function readJson(req: IncomingMessage): Promise { const chunks: Buffer[] = []; for await (const chunk of req) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); if (chunks.length === 0) return {}; return JSON.parse(Buffer.concat(chunks).toString("utf8")) as unknown; } function sendJson(res: ServerResponse, status: number, body: unknown): void { res.writeHead(status, { "content-type": "application/json; charset=utf-8" }); res.end(JSON.stringify(body)); } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function isStringRecord(value: unknown): value is Record { return isRecord(value) && Object.values(value).every((v) => typeof v === "string"); } function main(): void { const host = process.env.PI_LOOM_SERVICE_HOST ?? "0.0.0.0"; const port = Number(process.env.PI_LOOM_SERVICE_PORT ?? "8787"); const allowAnonymous = process.env.PI_LOOM_SERVICE_ALLOW_ANON === "true"; const tokens = readTokensFromEnv(); if (!allowAnonymous && Object.keys(tokens).length === 0) { throw new Error("Set PI_LOOM_SERVICE_TOKEN, PI_LOOM_SERVICE_TOKENS, or PI_LOOM_SERVICE_ALLOW_ANON=true"); } const tls = readTlsFromEnv(); const server: LoomServiceServer = tls ? createLoomHttpsServer({ tokens, allowAnonymous, tls }) : createLoomHttpServer({ tokens, allowAnonymous }); const protocol = tls ? "https" : "http"; server.listen(port, host, () => { console.error(`[pi-loom] service listening on ${protocol}://${host}:${port}`); }); } function readTlsFromEnv(): HttpsOptions | undefined { const keyPath = process.env.PI_LOOM_SERVICE_TLS_KEY; const certPath = process.env.PI_LOOM_SERVICE_TLS_CERT; const caPath = process.env.PI_LOOM_SERVICE_TLS_CA; if (!keyPath && !certPath && !caPath) return undefined; if (!keyPath || !certPath) throw new Error("Set both PI_LOOM_SERVICE_TLS_KEY and PI_LOOM_SERVICE_TLS_CERT for HTTPS"); return { key: readFileSync(keyPath), cert: readFileSync(certPath), ca: caPath ? readFileSync(caPath) : undefined, }; } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { main(); }