import { createServer } from "node:http"; import { randomBytes, timingSafeEqual, scryptSync, createHash } from "node:crypto"; import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; import { existsSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const publicDir = path.join(__dirname, "public"); const dataDir = process.env.DATA_DIR || path.join(__dirname, "data"); const dbPath = path.join(dataDir, "state.json"); const port = Number(process.env.PORT || 8080); const appOrigin = process.env.APP_ORIGIN || "https://mc.texisoft.com"; const sessionSecret = mustEnv("SESSION_SECRET"); const adminEmail = mustEnv("ADMIN_EMAIL").toLowerCase(); const adminPassword = mustEnv("ADMIN_PASSWORD"); const adminName = process.env.ADMIN_NAME || "Maxi"; const openClawBaseUrl = (process.env.OPENCLAW_BASE_URL || "http://openclaw-gateway-maxi:18790").replace(/\/+$/, ""); const openClawToken = process.env.OPENCLAW_GATEWAY_TOKEN || ""; const states = ["Backlog", "In Progress", "Review", "Done"]; const priorities = ["Low", "Normal", "High", "Urgent"]; let db; function mustEnv(name) { const value = process.env[name]; if (!value || value.length < 12) { throw new Error(`${name} must be set to a non-trivial value`); } return value; } function hashPassword(password, salt = randomBytes(16).toString("hex")) { const hash = scryptSync(password, salt, 64).toString("hex"); return `${salt}:${hash}`; } function verifyPassword(password, stored) { const [salt, hash] = stored.split(":"); if (!salt || !hash) return false; const candidate = scryptSync(password, salt, 64); const expected = Buffer.from(hash, "hex"); return candidate.length === expected.length && timingSafeEqual(candidate, expected); } function signSession(payload) { const body = Buffer.from(JSON.stringify(payload)).toString("base64url"); const sig = createHash("sha256").update(`${body}.${sessionSecret}`).digest("base64url"); return `${body}.${sig}`; } function verifySession(token) { if (!token || !token.includes(".")) return null; const [body, sig] = token.split("."); const expected = createHash("sha256").update(`${body}.${sessionSecret}`).digest("base64url"); if (!timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return null; const payload = JSON.parse(Buffer.from(body, "base64url").toString("utf8")); if (!payload.exp || payload.exp < Date.now()) return null; return payload; } async function loadDb() { await mkdir(dataDir, { recursive: true }); if (existsSync(dbPath)) { db = JSON.parse(await readFile(dbPath, "utf8")); } else { db = { users: [{ id: "admin", email: adminEmail, name: adminName, role: "Admin", passwordHash: hashPassword(adminPassword), createdAt: new Date().toISOString() }], tasks: seedTasks(), settings: { openClawBaseUrl, defaultAssignee: "luna", timezone: "Europe/Berlin" } }; await saveDb(); } } function seedTasks() { const now = new Date().toISOString(); return [ { id: randomBytes(12).toString("hex"), title: "Maxis Mission Control verifizieren", detail: "Login, Board und Einstellungen pruefen; OpenClaw-Verbindung muss auf Maxis Gateway zeigen.", state: "Review", priority: "High", assignee: "luna", createdAt: now, updatedAt: now }, { id: randomBytes(12).toString("hex"), title: "Erste Maxi-Aufgaben sammeln", detail: "Backlog als Eingang fuer Maxis operative Aufgaben nutzen.", state: "Backlog", priority: "Normal", assignee: "luna", createdAt: now, updatedAt: now } ]; } async function saveDb() { const tmp = `${dbPath}.${process.pid}.tmp`; await writeFile(tmp, JSON.stringify(db, null, 2)); await rename(tmp, dbPath); } function send(res, status, value, headers = {}) { const isJson = typeof value !== "string" && !Buffer.isBuffer(value); const body = isJson ? JSON.stringify(value) : value; res.writeHead(status, { "content-type": isJson ? "application/json; charset=utf-8" : headers["content-type"] || "text/plain; charset=utf-8", "cache-control": "no-store", "x-content-type-options": "nosniff", "referrer-policy": "no-referrer", ...headers }); res.end(body); } function getCookie(req, name) { const header = req.headers.cookie || ""; return header.split(";").map((x) => x.trim()).find((x) => x.startsWith(`${name}=`))?.slice(name.length + 1); } async function readJson(req) { const chunks = []; for await (const chunk of req) chunks.push(chunk); if (!chunks.length) return {}; return JSON.parse(Buffer.concat(chunks).toString("utf8")); } function requireAuth(req, res) { const session = verifySession(getCookie(req, "mc_session")); if (!session) { send(res, 401, { error: "Not authenticated" }); return null; } return session; } async function openClawStatus() { const started = Date.now(); try { const health = await fetch(`${openClawBaseUrl}/healthz`, { headers: openClawToken ? { authorization: `Bearer ${openClawToken}` } : {}, signal: AbortSignal.timeout(4000) }); let toolResult = null; let toolOk = false; try { const tool = await fetch(`${openClawBaseUrl}/tools/invoke`, { method: "POST", headers: { "content-type": "application/json", ...(openClawToken ? { authorization: `Bearer ${openClawToken}` } : {}) }, body: JSON.stringify({ tool: "get_goal", args: {} }), signal: AbortSignal.timeout(5000) }); toolOk = tool.ok; toolResult = tool.ok ? "tool invoke accepted" : `tool invoke HTTP ${tool.status}`; } catch (error) { toolResult = error.message; } return { ok: health.ok, baseUrl: openClawBaseUrl, healthStatus: health.status, toolOk, toolResult, latencyMs: Date.now() - started, checkedAt: new Date().toISOString() }; } catch (error) { return { ok: false, baseUrl: openClawBaseUrl, error: error.message, latencyMs: Date.now() - started, checkedAt: new Date().toISOString() }; } } async function handleApi(req, res) { const url = new URL(req.url, "http://localhost"); if (url.pathname === "/api/login" && req.method === "POST") { const body = await readJson(req); const user = db.users.find((x) => x.email === String(body.email || "").toLowerCase()); if (!user || !verifyPassword(String(body.password || ""), user.passwordHash)) { send(res, 401, { error: "Invalid email or password" }); return; } const token = signSession({ sub: user.id, email: user.email, name: user.name, role: user.role, exp: Date.now() + 1000 * 60 * 60 * 12 }); send(res, 200, { user: publicUser(user) }, { "set-cookie": `mc_session=${token}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=43200` }); return; } if (url.pathname === "/api/logout" && req.method === "POST") { send(res, 200, { ok: true }, { "set-cookie": "mc_session=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0" }); return; } const session = requireAuth(req, res); if (!session) return; if (url.pathname === "/api/session" && req.method === "GET") { const user = db.users.find((x) => x.id === session.sub); send(res, 200, { user: user ? publicUser(user) : null }); return; } if (url.pathname === "/api/tasks" && req.method === "GET") { send(res, 200, { tasks: db.tasks }); return; } if (url.pathname === "/api/tasks" && req.method === "POST") { const body = await readJson(req); const title = String(body.title || "").trim(); if (!title) { send(res, 400, { error: "Title is required" }); return; } const now = new Date().toISOString(); const task = { id: randomBytes(12).toString("hex"), title, detail: String(body.detail || "").trim(), state: states.includes(body.state) ? body.state : "Backlog", priority: priorities.includes(body.priority) ? body.priority : "Normal", assignee: String(body.assignee || db.settings.defaultAssignee || "luna").trim(), createdAt: now, updatedAt: now }; db.tasks.unshift(task); await saveDb(); send(res, 201, { task }); return; } const taskMatch = url.pathname.match(/^\/api\/tasks\/([a-f0-9]+)$/); if (taskMatch && req.method === "PATCH") { const task = db.tasks.find((x) => x.id === taskMatch[1]); if (!task) { send(res, 404, { error: "Task not found" }); return; } const body = await readJson(req); if (typeof body.title === "string" && body.title.trim()) task.title = body.title.trim(); if (typeof body.detail === "string") task.detail = body.detail.trim(); if (states.includes(body.state)) task.state = body.state; if (priorities.includes(body.priority)) task.priority = body.priority; if (typeof body.assignee === "string") task.assignee = body.assignee.trim(); task.updatedAt = new Date().toISOString(); await saveDb(); send(res, 200, { task }); return; } if (taskMatch && req.method === "DELETE") { db.tasks = db.tasks.filter((x) => x.id !== taskMatch[1]); await saveDb(); send(res, 200, { ok: true }); return; } if (url.pathname === "/api/settings" && req.method === "GET") { const status = await openClawStatus(); send(res, 200, { settings: db.settings, openClaw: status }); return; } if (url.pathname === "/api/settings" && req.method === "PATCH") { const body = await readJson(req); if (typeof body.defaultAssignee === "string") db.settings.defaultAssignee = body.defaultAssignee.trim() || "luna"; if (typeof body.timezone === "string") db.settings.timezone = body.timezone.trim() || "Europe/Berlin"; await saveDb(); send(res, 200, { settings: db.settings, openClaw: await openClawStatus() }); return; } if (url.pathname === "/api/openclaw/status" && req.method === "GET") { send(res, 200, await openClawStatus()); return; } send(res, 404, { error: "Not found" }); } function publicUser(user) { return { id: user.id, email: user.email, name: user.name, role: user.role }; } async function serveStatic(req, res) { const url = new URL(req.url, "http://localhost"); const relative = url.pathname === "/" ? "index.html" : url.pathname.slice(1); const safe = path.normalize(relative).replace(/^(\.\.[/\\])+/, ""); let filePath = path.join(publicDir, safe); if (!filePath.startsWith(publicDir)) filePath = path.join(publicDir, "index.html"); try { const file = await readFile(filePath); const ext = path.extname(filePath); const type = ext === ".html" ? "text/html; charset=utf-8" : ext === ".css" ? "text/css; charset=utf-8" : ext === ".js" ? "text/javascript; charset=utf-8" : "application/octet-stream"; send(res, 200, file, { "content-type": type, "cache-control": "no-store, no-cache, must-revalidate, proxy-revalidate" }); } catch { const file = await readFile(path.join(publicDir, "index.html")); send(res, 200, file, { "content-type": "text/html; charset=utf-8" }); } } await loadDb(); createServer(async (req, res) => { try { if (req.url === "/health/live") { send(res, 200, { ok: true, status: "live" }); return; } if (req.url?.startsWith("/api/")) { await handleApi(req, res); return; } await serveStatic(req, res); } catch (error) { send(res, 500, { error: "Internal server error", detail: process.env.NODE_ENV === "production" ? undefined : error.message }); } }).listen(port, () => { console.log(`Maxi's Mission Control listening on ${port}`); });