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
+9
View File
@@ -0,0 +1,9 @@
PORT=8080
NODE_ENV=production
APP_ORIGIN=https://mc.texisoft.com
SESSION_SECRET=change-me
ADMIN_EMAIL=maxi@texisoft.com
ADMIN_PASSWORD=change-me
ADMIN_NAME=Maxi
OPENCLAW_BASE_URL=http://openclaw-gateway-maxi:18790
OPENCLAW_GATEWAY_TOKEN=change-me
+5
View File
@@ -0,0 +1,5 @@
.env
data/
node_modules/
npm-debug.log*
.DS_Store
+13
View File
@@ -0,0 +1,13 @@
FROM node:24-bookworm-slim
WORKDIR /app
ENV NODE_ENV=production
COPY package.json server.js ./
COPY public ./public
RUN mkdir -p /app/data && chown -R node:node /app
USER node
EXPOSE 8080
CMD ["node", "server.js"]
+20
View File
@@ -0,0 +1,20 @@
# Maxi's Mission Control
Schlankes Mission Control fuer Maxi unter `https://mc.texisoft.com`.
## Features
- Login fuer den initialen Admin
- Taskboard mit Backlog, In Progress, Review, Done
- Einstellungen mit OpenClaw-Verbindungsstatus
- Verbindung zu Maxis OpenClaw Gateway, nicht zu Baos Nexus/OpenClaw
## Betrieb
Die Produktion laeuft per Docker Compose im Projektpfad:
```bash
docker compose up -d --force-recreate --build
```
Noetige Secrets liegen in `.env` und werden nicht ins Repo geschrieben.
+43
View File
@@ -0,0 +1,43 @@
name: maxis-mission-control
services:
app:
build:
context: .
container_name: maxis-mission-control
restart: always
env_file:
- .env
environment:
PORT: 8080
NODE_ENV: production
DATA_DIR: /app/data
OPENCLAW_BASE_URL: ${OPENCLAW_BASE_URL:-http://openclaw-gateway-maxi:18790}
volumes:
- ./data:/app/data
healthcheck:
test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:8080/health/live').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
networks:
- proxy
- openclaw-maxi_default
labels:
- "traefik.enable=true"
- "traefik.http.routers.maxis-mission-control.rule=Host(`mc.texisoft.com`)"
- "traefik.http.routers.maxis-mission-control.tls=true"
- "traefik.http.routers.maxis-mission-control.tls.certresolver=letsencrypt"
- "traefik.http.services.maxis-mission-control.loadbalancer.server.port=8080"
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
networks:
proxy:
external: true
openclaw-maxi_default:
external: true
+13
View File
@@ -0,0 +1,13 @@
{
"name": "maxis-mission-control",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"start": "node server.js",
"check": "node --check server.js"
},
"engines": {
"node": ">=24"
}
}
+302
View File
@@ -0,0 +1,302 @@
const states = ["Backlog", "In Progress", "Review", "Done"];
const priorities = ["Low", "Normal", "High", "Urgent"];
const app = document.querySelector("#app");
const state = {
user: null,
tasks: [],
settings: null,
openClaw: null,
view: "board",
error: ""
};
async function api(path, options = {}) {
const response = await fetch(path, {
credentials: "same-origin",
headers: { "content-type": "application/json", ...(options.headers || {}) },
...options
});
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
return data;
}
async function init() {
try {
const session = await api("/api/session");
state.user = session.user;
await Promise.all([loadTasks(), loadSettings()]);
renderApp();
} catch {
renderLogin();
}
}
function renderLogin() {
app.innerHTML = `
<section class="login-shell">
<form class="login" id="login-form">
<h1>Maxi Mission Control</h1>
<p>Schlanke Kommandozentrale fuer Aufgaben und OpenClaw-Status.</p>
<label class="field">E-Mail
<input name="email" type="email" autocomplete="username" required autofocus>
</label>
<label class="field">Passwort
<input name="password" type="password" autocomplete="current-password" required>
</label>
<button class="primary field" type="submit">Anmelden</button>
<div class="error" id="login-error" role="alert"></div>
</form>
</section>
`;
document.querySelector("#login-form").addEventListener("submit", async (event) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
try {
const result = await api("/api/login", {
method: "POST",
body: JSON.stringify({ email: form.get("email"), password: form.get("password") })
});
state.user = result.user;
await Promise.all([loadTasks(), loadSettings()]);
renderApp();
} catch (error) {
document.querySelector("#login-error").textContent = error.message;
}
});
}
async function loadTasks() {
const result = await api("/api/tasks");
state.tasks = result.tasks;
}
async function loadSettings() {
const result = await api("/api/settings");
state.settings = result.settings;
state.openClaw = result.openClaw;
}
function renderApp() {
app.innerHTML = `
<section class="app-shell">
<aside class="sidebar">
<div class="brand">
<strong>Maxi Mission Control</strong>
<span>mc.texisoft.com</span>
</div>
<nav class="nav">
<button data-view="board" class="${state.view === "board" ? "active" : ""}" title="Taskboard">▦ Taskboard</button>
<button data-view="settings" class="${state.view === "settings" ? "active" : ""}" title="Einstellungen">⚙ Einstellungen</button>
</nav>
<div class="user">
<div><strong>${escapeHtml(state.user?.name || "Maxi")}</strong><br><small>${escapeHtml(state.user?.role || "Admin")}</small></div>
<button class="ghost" id="logout">Abmelden</button>
</div>
</aside>
<section class="content">
${state.view === "settings" ? settingsView() : boardView()}
</section>
</section>
${taskDialog()}
`;
document.querySelectorAll("[data-view]").forEach((button) => {
button.addEventListener("click", async () => {
state.view = button.dataset.view;
if (state.view === "settings") await loadSettings();
renderApp();
});
});
document.querySelector("#logout").addEventListener("click", async () => {
await api("/api/logout", { method: "POST" });
state.user = null;
renderLogin();
});
if (state.view === "board") bindBoard();
if (state.view === "settings") bindSettings();
}
function boardView() {
const connected = state.openClaw?.ok;
return `
<header class="topbar">
<div class="view-title">
<h1>Taskboard</h1>
<p>${state.tasks.length} Aufgaben · Assignee ${escapeHtml(state.settings?.defaultAssignee || "luna")}</p>
</div>
<div class="actions">
<span class="status-pill"><span class="dot ${connected ? "ok" : ""}"></span>OpenClaw ${connected ? "verbunden" : "pruefen"}</span>
<button class="primary" id="new-task">+ Aufgabe</button>
</div>
</header>
<section class="board">
${states.map((name) => column(name)).join("")}
</section>
`;
}
function column(name) {
const tasks = state.tasks.filter((task) => task.state === name);
return `
<section class="column" data-state="${name}">
<h2>${name}<span class="count">${tasks.length}</span></h2>
${tasks.map(taskCard).join("") || `<p class="meta">Keine Aufgaben</p>`}
</section>
`;
}
function taskCard(task) {
const index = states.indexOf(task.state);
return `
<article class="task">
<div>
<h3>${escapeHtml(task.title)}</h3>
${task.detail ? `<p>${escapeHtml(task.detail)}</p>` : ""}
</div>
<div class="task-footer">
<span class="badge">${escapeHtml(task.priority)}</span>
<div class="task-controls">
<button class="icon-button" data-move="${task.id}" data-state="${states[Math.max(0, index - 1)]}" title="Zurueck"></button>
<button class="icon-button" data-edit="${task.id}" title="Bearbeiten">✎</button>
<button class="icon-button" data-move="${task.id}" data-state="${states[Math.min(states.length - 1, index + 1)]}" title="Weiter"></button>
</div>
</div>
<span class="meta">${escapeHtml(task.assignee || "luna")}</span>
</article>
`;
}
function taskDialog() {
return `
<dialog id="task-dialog">
<form id="task-form">
<h2 id="dialog-title">Aufgabe</h2>
<input type="hidden" name="id">
<label class="field">Titel
<input name="title" required>
</label>
<label class="field">Details
<textarea name="detail"></textarea>
</label>
<div class="grid">
<label class="field">Status
<select name="state">${states.map((x) => `<option>${x}</option>`).join("")}</select>
</label>
<label class="field">Prioritaet
<select name="priority">${priorities.map((x) => `<option>${x}</option>`).join("")}</select>
</label>
</div>
<label class="field">Assignee
<input name="assignee" value="${escapeHtml(state.settings?.defaultAssignee || "luna")}">
</label>
<div class="dialog-actions">
<button class="ghost" type="button" id="cancel-task">Abbrechen</button>
<button class="primary" type="submit">Speichern</button>
</div>
</form>
</dialog>
`;
}
function bindBoard() {
const dialog = document.querySelector("#task-dialog");
const form = document.querySelector("#task-form");
document.querySelector("#new-task").addEventListener("click", () => {
form.reset();
form.elements.assignee.value = state.settings?.defaultAssignee || "luna";
dialog.showModal();
});
document.querySelector("#cancel-task").addEventListener("click", () => dialog.close());
document.querySelectorAll("[data-move]").forEach((button) => {
button.addEventListener("click", async () => {
await api(`/api/tasks/${button.dataset.move}`, { method: "PATCH", body: JSON.stringify({ state: button.dataset.state }) });
await loadTasks();
renderApp();
});
});
document.querySelectorAll("[data-edit]").forEach((button) => {
button.addEventListener("click", () => {
const task = state.tasks.find((x) => x.id === button.dataset.edit);
form.elements.id.value = task.id;
form.elements.title.value = task.title;
form.elements.detail.value = task.detail || "";
form.elements.state.value = task.state;
form.elements.priority.value = task.priority;
form.elements.assignee.value = task.assignee || "";
dialog.showModal();
});
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
const data = Object.fromEntries(new FormData(form));
const path = data.id ? `/api/tasks/${data.id}` : "/api/tasks";
await api(path, { method: data.id ? "PATCH" : "POST", body: JSON.stringify(data) });
dialog.close();
await loadTasks();
renderApp();
});
}
function settingsView() {
const openClaw = state.openClaw || {};
return `
<header class="topbar">
<div class="view-title">
<h1>Einstellungen</h1>
<p>Admin-Konfiguration und Maxi-OpenClaw-Verbindung.</p>
</div>
<button class="ghost" id="refresh-settings">Aktualisieren</button>
</header>
<section class="panel">
<h2>OpenClaw</h2>
<p class="status-pill"><span class="dot ${openClaw.ok ? "ok" : ""}"></span>${openClaw.ok ? "Verbunden" : "Nicht verbunden"}</p>
<p class="meta">Gateway: ${escapeHtml(openClaw.baseUrl || state.settings?.openClawBaseUrl || "")}</p>
<p class="meta">Health: ${escapeHtml(String(openClaw.healthStatus || openClaw.error || "unbekannt"))} · Tool: ${escapeHtml(String(openClaw.toolResult || "nicht geprueft"))} · ${escapeHtml(String(openClaw.latencyMs || 0))} ms</p>
</section>
<section class="panel">
<h2>Board</h2>
<form id="settings-form">
<div class="grid">
<label class="field">Default-Assignee
<input name="defaultAssignee" value="${escapeHtml(state.settings?.defaultAssignee || "luna")}">
</label>
<label class="field">Zeitzone
<input name="timezone" value="${escapeHtml(state.settings?.timezone || "Europe/Berlin")}">
</label>
</div>
<div class="dialog-actions">
<button class="primary" type="submit">Speichern</button>
</div>
</form>
</section>
`;
}
function bindSettings() {
document.querySelector("#refresh-settings").addEventListener("click", async () => {
await loadSettings();
renderApp();
});
document.querySelector("#settings-form").addEventListener("submit", async (event) => {
event.preventDefault();
const data = Object.fromEntries(new FormData(event.currentTarget));
await api("/api/settings", { method: "PATCH", body: JSON.stringify(data) });
await loadSettings();
renderApp();
});
}
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
init();
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Maxi Mission Control</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<main id="app"></main>
<script type="module" src="/app.js"></script>
</body>
</html>
+407
View File
@@ -0,0 +1,407 @@
:root {
color-scheme: light;
--bg: #f5f7f8;
--panel: #ffffff;
--panel-soft: #eef3f1;
--text: #17201c;
--muted: #66736d;
--line: #d9e1de;
--accent: #256f5b;
--accent-strong: #174c3e;
--warn: #a45b14;
--danger: #9d2f2f;
--ok: #1c7a4c;
--shadow: 0 10px 28px rgba(23, 32, 28, 0.08);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--text);
font: 15px/1.45 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
letter-spacing: 0;
}
button, input, textarea, select {
font: inherit;
}
button {
border: 0;
cursor: pointer;
}
.login-shell {
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
}
.login {
width: min(420px, 100%);
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: var(--shadow);
padding: 28px;
}
.login h1, .view-title h1 {
margin: 0;
font-size: 28px;
line-height: 1.1;
}
.login p, .view-title p {
margin: 8px 0 0;
color: var(--muted);
}
.field {
display: grid;
gap: 6px;
margin-top: 16px;
}
label {
color: var(--muted);
font-size: 13px;
font-weight: 650;
}
input, textarea, select {
width: 100%;
min-height: 40px;
border: 1px solid var(--line);
border-radius: 6px;
background: #fff;
color: var(--text);
padding: 9px 10px;
outline: none;
}
textarea {
resize: vertical;
min-height: 84px;
}
input:focus, textarea:focus, select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(37, 111, 91, 0.14);
}
.primary, .ghost, .danger {
min-height: 38px;
border-radius: 6px;
padding: 8px 12px;
font-weight: 700;
}
.primary {
background: var(--accent);
color: #fff;
}
.primary:hover {
background: var(--accent-strong);
}
.ghost {
border: 1px solid var(--line);
background: #fff;
color: var(--text);
}
.danger {
background: #fff1f1;
color: var(--danger);
border: 1px solid #edc9c9;
}
.app-shell {
min-height: 100vh;
display: grid;
grid-template-columns: 248px minmax(0, 1fr);
}
.sidebar {
border-right: 1px solid var(--line);
background: #fff;
padding: 20px 16px;
display: flex;
flex-direction: column;
gap: 20px;
}
.brand {
display: grid;
gap: 4px;
}
.brand strong {
font-size: 18px;
}
.brand span, .user small, .meta {
color: var(--muted);
font-size: 13px;
}
.nav {
display: grid;
gap: 8px;
}
.nav button {
display: flex;
align-items: center;
gap: 9px;
width: 100%;
min-height: 40px;
border-radius: 6px;
background: transparent;
color: var(--text);
padding: 8px 10px;
text-align: left;
}
.nav button.active {
background: var(--panel-soft);
color: var(--accent-strong);
font-weight: 750;
}
.user {
margin-top: auto;
display: grid;
gap: 12px;
}
.content {
min-width: 0;
padding: 24px;
}
.topbar {
display: flex;
gap: 16px;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
}
.status-pill {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 32px;
padding: 6px 10px;
border: 1px solid var(--line);
border-radius: 999px;
background: #fff;
color: var(--muted);
white-space: nowrap;
}
.dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--warn);
}
.dot.ok {
background: var(--ok);
}
.board {
display: grid;
grid-template-columns: repeat(4, minmax(220px, 1fr));
gap: 12px;
align-items: start;
}
.column {
min-width: 0;
background: #ecf1ef;
border: 1px solid var(--line);
border-radius: 8px;
padding: 10px;
min-height: 520px;
}
.column h2 {
display: flex;
justify-content: space-between;
align-items: center;
margin: 0 0 10px;
font-size: 14px;
text-transform: uppercase;
color: var(--muted);
}
.count {
display: inline-grid;
place-items: center;
min-width: 24px;
height: 24px;
border-radius: 999px;
background: #fff;
color: var(--text);
}
.task {
display: grid;
gap: 10px;
background: #fff;
border: 1px solid var(--line);
border-radius: 8px;
padding: 12px;
margin-bottom: 10px;
box-shadow: 0 4px 14px rgba(23, 32, 28, 0.04);
}
.task h3 {
margin: 0;
font-size: 15px;
}
.task p {
margin: 0;
color: var(--muted);
font-size: 13px;
overflow-wrap: anywhere;
}
.task-footer {
display: flex;
gap: 6px;
align-items: center;
justify-content: space-between;
}
.badge {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 3px 8px;
border-radius: 999px;
background: var(--panel-soft);
color: var(--accent-strong);
font-size: 12px;
font-weight: 750;
}
.task-controls {
display: flex;
gap: 4px;
}
.icon-button {
width: 30px;
height: 30px;
border-radius: 6px;
border: 1px solid var(--line);
background: #fff;
color: var(--text);
}
.panel {
background: #fff;
border: 1px solid var(--line);
border-radius: 8px;
padding: 18px;
max-width: 760px;
}
.panel + .panel {
margin-top: 14px;
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.error {
margin-top: 12px;
color: var(--danger);
font-weight: 700;
}
dialog {
width: min(560px, calc(100vw - 32px));
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: var(--shadow);
}
dialog::backdrop {
background: rgba(18, 26, 23, 0.35);
}
.dialog-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 16px;
}
@media (max-width: 980px) {
.app-shell {
grid-template-columns: 1fr;
}
.sidebar {
position: sticky;
top: 0;
z-index: 2;
border-right: 0;
border-bottom: 1px solid var(--line);
padding: 12px;
}
.nav, .user {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.board {
grid-template-columns: repeat(2, minmax(220px, 1fr));
}
}
@media (max-width: 640px) {
.content {
padding: 16px;
}
.topbar {
display: grid;
}
.actions {
justify-content: flex-start;
}
.board, .grid {
grid-template-columns: 1fr;
}
.column {
min-height: 260px;
}
}
+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}`);
});