Initial slim mission control

This commit is contained in:
2026-06-26 14:15:24 +02:00
commit f8855279f1
10 changed files with 1170 additions and 0 deletions
+345
View File
@@ -0,0 +1,345 @@
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": ext === ".html" ? "no-store" : "public, max-age=3600" });
} 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}`);
});