// sched custom single-binary: embedded engine + admin API + UI morda + MCP (Streamable HTTP) + storage (MariaDB/MySQL). // One HTTP server on :8080, routed: // /api/* → createAdminApi.handleRequest (health open, rest Bearer SCHED_ADMIN_KEY) // /mcp → createMcpHttpHandler (Streamable HTTP MCP, projection of admin API) // /sched-ui.bundle.js → static UI bundle // / → UI morda (html) // Engine via createDaemon (runs ticks + sync; we supply storage+adminApi ourselves on OUR server). import { createServer } from 'node:http'; import { readFileSync } from 'node:fs'; import { pathToFileURL } from 'node:url'; import { createDaemon } from '@schedjs/daemon'; import { createAdminApi } from '@schedjs/admin-api'; import { createMysqlStorage, dbNameFromUrl } from '@schedjs/storage-mysql'; import { createPool } from 'mysql2/promise'; import { AdminApiClient } from '@schedjs/mcp'; // Subpath imports are blocked by npm `exports` masks — import the dist modules by // absolute file URL (a direct file reference, not a package specifier). const { createMcpHttpHandler } = await import(pathToFileURL('/app/node_modules/@schedjs/mcp/dist/http.js').href); const { mordaHtml, DEFAULT_UI_BUNDLE } = await import(pathToFileURL('/app/node_modules/@schedjs/ui/dist/morda.js').href); const PORT = Number(process.env.SCHED_PORT ?? 8080); const HOST = process.env.SCHED_HOST ?? '0.0.0.0'; const ADMIN_KEY = process.env.SCHED_ADMIN_KEY; // Bearer; unset → open (dev) const TASKS = process.env.SCHED_TASKS ?? '/app/tasks.json'; // --- storage (MariaDB/MySQL) --- const MYSQL_URL = process.env.MYSQL_URL; if (!MYSQL_URL) throw new Error('MYSQL_URL is not set — export MYSQL_URL='); const pool = createPool(MYSQL_URL); const storage = await createMysqlStorage(pool); // self-migrates on open const dbName = dbNameFromUrl(MYSQL_URL); // --- daemon: engine only (we own the http server) --- const daemon = createDaemon({ storage, tasksPath: TASKS, // no `admin` here — createDaemon would bind its own /api-only server; we route ourselves }); await daemon.start(); const engine = daemon.engine; const adminApi = createAdminApi({ engine, storage, auth: ADMIN_KEY ? { apiKey: ADMIN_KEY } : undefined, version: '0.12.1-custom' }); // --- MCP: projection of admin API via HTTP client to ourselves --- // AdminApiClient appends paths like '/tasks' to baseUrl → must end at the /api mount point. const ADMIN_BASE = `http://127.0.0.1:${PORT}/api`; const mcpClient = new AdminApiClient(ADMIN_BASE, ADMIN_KEY); const mcpHandler = createMcpHttpHandler({ client: mcpClient, readonly: process.env.SCHED_MCP_READONLY === '1' }); // --- static UI bundle bytes --- let uiBundle = null; try { uiBundle = readFileSync(DEFAULT_UI_BUNDLE); } catch { /* bundle missing → morda 404s its script */ } // One HTTP server, auth at the boundary. OPEN (no token): /api/health (probe) + the UI itself // (/ and /sched-ui.bundle.js — static login page/JS bundle, ZERO data; data comes from /api/*). // AUTH required (Bearer SCHED_ADMIN_KEY): /api/* (except health) and /mcp — the actual data plane. // This is the vitya correction: morda stays reachable (it's just the login form), data plane is locked. const AUTH_OPEN_PATHS = ['/api/health', '/sched-ui.bundle.js']; function isOpenPath(p) { return AUTH_OPEN_PATHS.includes(p) || p === '/'; // UI root = static login page, no data } function isAuthorized(req) { if (!ADMIN_KEY) return true; // dev mode, open const h = req.headers.authorization; return h === `Bearer ${ADMIN_KEY}`; } const server = createServer(async (req, res) => { const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`); const p = url.pathname; try { if (!isOpenPath(p) && !isAuthorized(req)) { res.writeHead(401, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: 'unauthorized' })); return; } if (p === '/api' || p.startsWith('/api/')) { req.url = p.replace(/^\/api/, '') + url.search; // strip /api mount prefix await adminApi.handleRequest(req, res); return; } if (p === '/mcp' || p.startsWith('/mcp/')) { await mcpHandler(req, res); return; } if (p === '/sched-ui.bundle.js' && uiBundle) { res.writeHead(200, { 'content-type': 'application/javascript' }); res.end(uiBundle); return; } // UI morda for everything else (root + non-api paths) res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); res.end(mordaHtml({ apiBase: '/api', bundleSrc: '/sched-ui.bundle.js', tokenKey: 'sched-token' })); } catch (err) { res.writeHead(500, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) })); } }); server.listen(PORT, HOST, () => { process.stderr.write(`sched custom up: http://${HOST}:${PORT} (storage=mysql db=${dbName}, admin_auth=${ADMIN_KEY ? 'on' : 'OFF'}, mcp=on)\n`); }); // Graceful stop on SIGTERM for (const sig of ['SIGTERM', 'SIGINT']) { process.on(sig, () => { daemon.stop(); server.close(); pool.end(); process.exit(0); }); }