commit f8855279f1f8c621d7f7f44e8134e7add5c5cd0c Author: Iris DevOps Date: Fri Jun 26 14:15:24 2026 +0200 Initial slim mission control diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a358a81 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..99e50db --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +data/ +node_modules/ +npm-debug.log* +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..51398f3 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..54ae308 --- /dev/null +++ b/README.md @@ -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. diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..85b376a --- /dev/null +++ b/compose.yaml @@ -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 diff --git a/package.json b/package.json new file mode 100644 index 0000000..1f445ee --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..7266ade --- /dev/null +++ b/public/app.js @@ -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 = ` +
+ +
+ `; + 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 = ` +
+ +
+ ${state.view === "settings" ? settingsView() : boardView()} +
+
+ ${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 ` +
+
+

Taskboard

+

${state.tasks.length} Aufgaben · Assignee ${escapeHtml(state.settings?.defaultAssignee || "luna")}

+
+
+ OpenClaw ${connected ? "verbunden" : "pruefen"} + +
+
+
+ ${states.map((name) => column(name)).join("")} +
+ `; +} + +function column(name) { + const tasks = state.tasks.filter((task) => task.state === name); + return ` +
+

${name}${tasks.length}

+ ${tasks.map(taskCard).join("") || `

Keine Aufgaben

`} +
+ `; +} + +function taskCard(task) { + const index = states.indexOf(task.state); + return ` +
+
+

${escapeHtml(task.title)}

+ ${task.detail ? `

${escapeHtml(task.detail)}

` : ""} +
+ + ${escapeHtml(task.assignee || "luna")} +
+ `; +} + +function taskDialog() { + return ` + +
+

Aufgabe

+ + + +
+ + +
+ +
+ + +
+
+
+ `; +} + +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 ` +
+
+

Einstellungen

+

Admin-Konfiguration und Maxi-OpenClaw-Verbindung.

+
+ +
+
+

OpenClaw

+

${openClaw.ok ? "Verbunden" : "Nicht verbunden"}

+

Gateway: ${escapeHtml(openClaw.baseUrl || state.settings?.openClawBaseUrl || "")}

+

Health: ${escapeHtml(String(openClaw.healthStatus || openClaw.error || "unbekannt"))} · Tool: ${escapeHtml(String(openClaw.toolResult || "nicht geprueft"))} · ${escapeHtml(String(openClaw.latencyMs || 0))} ms

+
+
+

Board

+
+
+ + +
+
+ +
+
+
+ `; +} + +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("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +init(); diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..8659a36 --- /dev/null +++ b/public/index.html @@ -0,0 +1,13 @@ + + + + + + Maxi Mission Control + + + +
+ + + diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..1ad91d0 --- /dev/null +++ b/public/styles.css @@ -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; + } +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..d295c6b --- /dev/null +++ b/server.js @@ -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}`); +});