@javierfrancoleal/dsh-supervisor 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/server.js ADDED
@@ -0,0 +1,429 @@
1
+ #!/usr/bin/env node
2
+ // supervisor/server.js — J.Franco suite supervisor backend.
3
+ // Created by J.Franco. MIT license. Zero dependencies: runs with any Node ≥24.
4
+ //
5
+ // Real local logins (scrypt-hashed passwords), owner/lead/employee roles,
6
+ // field reports, a feedback queue, a decisions.md editor, and the central
7
+ // dashboard — the business owner's control panel for the agentic suite.
8
+ //
9
+ // Storage modes:
10
+ // default — markdown files (simple, perfect for small businesses)
11
+ // --db — SQLite (node:sqlite) for 100+ employee deployments
12
+ // Network modes:
13
+ // --lan — bind 0.0.0.0 so tablets/phones on the WiFi can reach it
14
+ // --https — also serve HTTPS on port 8443 (certificates in ~/.dsh/certs,
15
+ // generated with tools/make-cert.sh)
16
+ //
17
+ // Usage:
18
+ // node server.js init --owner <name> --password <pw> [--employee <name> --employee-password <pw>] [--db]
19
+ // node server.js [--lan] [--https] [--db]
20
+ const http = require("http");
21
+ const https = require("https");
22
+ const fs = require("fs");
23
+ const path = require("path");
24
+ const os = require("os");
25
+ const crypto = require("crypto");
26
+
27
+ const PORT = Number(process.env.DSH_SUPERVISOR_PORT || 8787);
28
+ const HTTPS_PORT = Number(process.env.DSH_SUPERVISOR_HTTPS_PORT || 8443);
29
+ const ARGS = process.argv.slice(2);
30
+ const LAN = ARGS.includes("--lan");
31
+ const HTTPS_ON = ARGS.includes("--https");
32
+ const USE_DB = ARGS.includes("--db");
33
+ const HOST = process.env.DSH_SUPERVISOR_HOST || (LAN ? "0.0.0.0" : "127.0.0.1");
34
+ const ROOT = path.join(__dirname, "..");
35
+ const UI_DIR = path.join(ROOT, "ui");
36
+ const DATA_DIR = process.env.DSH_SUPERVISOR_DATA || path.join(os.homedir(), ".dsh");
37
+ const USERS_FILE = path.join(DATA_DIR, "supervisor-users.json");
38
+ const REPORTS_FILE = path.join(DATA_DIR, "supervision.md");
39
+ const FEEDBACK_FILE = path.join(DATA_DIR, "feedback.md");
40
+ const DECISIONS_FILE = path.join(DATA_DIR, "decisions.md");
41
+ const DB_FILE = path.join(DATA_DIR, "supervisor.db");
42
+ const USAGE_FILE = path.join(DATA_DIR, "usage.md");
43
+ const LIVE_FILE = path.join(DATA_DIR, "live.json");
44
+ const SECTOR_FILE = path.join(DATA_DIR, "sector.json");
45
+ const PRICE_PER_MILLION = Number(process.env.DSH_PRICE_PER_MILLION || 0.28);
46
+ const TLS_KEY = process.env.DSH_TLS_KEY || path.join(DATA_DIR, "certs", "key.pem");
47
+ const TLS_CERT = process.env.DSH_TLS_CERT || path.join(DATA_DIR, "certs", "cert.pem");
48
+ const SESSION_TTL = 8 * 60 * 60 * 1000;
49
+
50
+ // ---------- SQLite storage (--db) ----------
51
+ let db = null;
52
+ if (USE_DB) {
53
+ const { DatabaseSync } = require("node:sqlite");
54
+ fs.mkdirSync(DATA_DIR, { recursive: true });
55
+ db = new DatabaseSync(DB_FILE);
56
+ db.exec(`
57
+ CREATE TABLE IF NOT EXISTS users (name TEXT PRIMARY KEY, salt TEXT, hash TEXT, role TEXT, created TEXT);
58
+ CREATE TABLE IF NOT EXISTS reports (id INTEGER PRIMARY KEY AUTOINCREMENT, ts TEXT, worker TEXT, text TEXT);
59
+ CREATE TABLE IF NOT EXISTS feedback (id INTEGER PRIMARY KEY AUTOINCREMENT, ts TEXT, worker TEXT, role TEXT, category TEXT, text TEXT);
60
+ CREATE TABLE IF NOT EXISTS decisions (id INTEGER PRIMARY KEY CHECK (id = 1), content TEXT, updated TEXT);
61
+ CREATE TABLE IF NOT EXISTS usage (id INTEGER PRIMARY KEY AUTOINCREMENT, ts TEXT, worker TEXT, tokens INTEGER, model TEXT);
62
+ `);
63
+ }
64
+
65
+ // ---------- CLI: init ----------
66
+ if (ARGS[0] === "init") {
67
+ const get = (k) => { const i = ARGS.indexOf(k); return i >= 0 && ARGS[i + 1] ? ARGS[i + 1] : undefined; };
68
+ const owner = get("--owner"), opw = get("--password");
69
+ if (!owner || !opw) {
70
+ console.error("usage: node server.js init --owner <name> --password <pw> [--owner-label <label>] [--employee <name> --employee-password <pw> --employee-label <label>] [--lead <name> --lead-password <pw> --lead-label <label>] [--db]");
71
+ process.exit(1);
72
+ }
73
+ fs.mkdirSync(DATA_DIR, { recursive: true });
74
+ if (db) { db.exec("CREATE TABLE IF NOT EXISTS users (name TEXT PRIMARY KEY, salt TEXT, hash TEXT, role TEXT, label TEXT, created TEXT)"); try { db.exec("ALTER TABLE users ADD COLUMN label TEXT"); } catch {} }
75
+ const add = (name, pw, role, label) => {
76
+ const salt = crypto.randomBytes(16).toString("hex");
77
+ const hash = crypto.scryptSync(pw, salt, 64).toString("hex");
78
+ if (db) db.prepare("INSERT OR REPLACE INTO users (name,salt,hash,role,label,created) VALUES (?,?,?,?,?,?)").run(name, salt, hash, role, label || name, new Date().toISOString());
79
+ else { const u = loadUsers(); u[name] = { salt, hash, role, label: label || name }; saveUsers(u); }
80
+ };
81
+ add(owner, opw, "owner", get("--owner-label"));
82
+ const ename = get("--employee"), epw = get("--employee-password");
83
+ if (ename && epw) add(ename, epw, "employee", get("--employee-label"));
84
+ const lname = get("--lead"), lpw = get("--lead-password");
85
+ if (lname && lpw) add(lname, lpw, "lead", get("--lead-label"));
86
+ console.log("Users created (" + (db ? "sqlite" : "json") + "): " + owner + " (owner)" + (ename && epw ? ", " + ename + " (employee)" : "") + (lname && lpw ? ", " + lname + " (lead)" : ""));
87
+ console.log("Now run: node server.js" + (USE_DB ? " --db" : "") + (LAN ? " --lan" : ""));
88
+ process.exit(0);
89
+ }
90
+
91
+ // ---------- storage helpers (file or sqlite) ----------
92
+ function loadUsers() {
93
+ if (db) {
94
+ const out = {};
95
+ for (const r of db.prepare("SELECT name,salt,hash,role,label FROM users").all()) out[r.name] = { salt: r.salt, hash: r.hash, role: r.role, label: r.label };
96
+ return out;
97
+ }
98
+ try { return JSON.parse(fs.readFileSync(USERS_FILE, "utf8")); } catch { return {}; }
99
+ }
100
+ function saveUsers(u) {
101
+ if (db) { /* inserts handled per-user */ return; }
102
+ fs.writeFileSync(USERS_FILE, JSON.stringify(u, null, 2));
103
+ }
104
+ function hashPw(pw, salt) { return crypto.scryptSync(pw, salt, 64).toString("hex"); }
105
+ function ts() { return new Date().toISOString(); }
106
+ function appendReport(worker, text) {
107
+ if (db) db.prepare("INSERT INTO reports (ts,worker,text) VALUES (?,?,?)").run(ts(), worker, String(text || "").trim());
108
+ else {
109
+ fs.mkdirSync(DATA_DIR, { recursive: true });
110
+ const head = fs.existsSync(REPORTS_FILE) ? "" : "# supervision.md — generated by the supervisor backend.\n\n";
111
+ fs.appendFileSync(REPORTS_FILE, head + "## " + ts() + " — report by " + worker + "\n\n" + String(text || "").trim() + "\n\n");
112
+ }
113
+ }
114
+ function appendFeedback(worker, role, category, text) {
115
+ if (db) db.prepare("INSERT INTO feedback (ts,worker,role,category,text) VALUES (?,?,?,?,?)").run(ts(), worker || "anonymous", role || "", category || "feedback", String(text || "").trim());
116
+ else {
117
+ fs.mkdirSync(DATA_DIR, { recursive: true });
118
+ const head = fs.existsSync(FEEDBACK_FILE) ? "" : "# feedback.md — generated by the supervisor backend.\n\n";
119
+ fs.appendFileSync(FEEDBACK_FILE, head + "## " + ts() + " — " + (category || "feedback") + " from " + (worker || "anonymous") + (role ? " (" + role + ")" : "") + "\n\n" + String(text || "").trim() + "\n\n");
120
+ }
121
+ }
122
+ function readReports(n = 200) {
123
+ if (db) return db.prepare("SELECT ts,worker,text FROM reports ORDER BY id DESC LIMIT ?").all(n).reverse()
124
+ .map((r) => "## " + r.ts + " — report by " + r.worker + "\n\n" + r.text).join("\n\n");
125
+ return readTail(REPORTS_FILE, n);
126
+ }
127
+ function readFeedback(n = 200) {
128
+ if (db) return db.prepare("SELECT ts,worker,role,category,text FROM feedback ORDER BY id DESC LIMIT ?").all(n).reverse()
129
+ .map((f) => "## " + f.ts + " — " + f.category + " from " + f.worker + (f.role ? " (" + f.role + ")" : "") + "\n\n" + f.text).join("\n\n");
130
+ return readTail(FEEDBACK_FILE, n);
131
+ }
132
+ function readDecisions() {
133
+ if (db) { const r = db.prepare("SELECT content FROM decisions WHERE id=1").get(); return r ? r.content : ""; }
134
+ try { return fs.readFileSync(DECISIONS_FILE, "utf8"); } catch { return ""; }
135
+ }
136
+ function writeDecisions(content) {
137
+ if (db) db.prepare("INSERT INTO decisions (id,content,updated) VALUES (1,?,?) ON CONFLICT(id) DO UPDATE SET content=excluded.content, updated=excluded.updated").run(String(content || ""), ts());
138
+ else fs.writeFileSync(DECISIONS_FILE, String(content || ""));
139
+ }
140
+ function appendUsage(worker, tokens, model) {
141
+ if (db) db.prepare("INSERT INTO usage (ts,worker,tokens,model) VALUES (?,?,?,?)").run(ts(), worker || "anonymous", Number(tokens) || 0, model || "unknown");
142
+ else {
143
+ fs.mkdirSync(DATA_DIR, { recursive: true });
144
+ const head = fs.existsSync(USAGE_FILE) ? "" : "# usage.md — token usage reported per session.\n\n";
145
+ fs.appendFileSync(USAGE_FILE, head + "## " + ts() + " — " + (worker || "anonymous") + " — " + (Number(tokens) || 0) + " tokens (" + (model || "unknown") + ")\n");
146
+ }
147
+ }
148
+ function usageRows() {
149
+ if (db) return db.prepare("SELECT worker, COUNT(*) sessions, SUM(tokens) tokens FROM usage GROUP BY worker ORDER BY tokens DESC").all()
150
+ .map((r) => ({ worker: r.worker, sessions: r.sessions, tokens: r.tokens || 0, cost: Math.round(((r.tokens || 0) / 1e6) * PRICE_PER_MILLION * 100) / 100 }));
151
+ const rows = new Map();
152
+ try {
153
+ for (const line of fs.readFileSync(USAGE_FILE, "utf8").split("\n")) {
154
+ const m = /^## .+ — (\S+) — (\d+) tokens/.exec(line);
155
+ if (!m) continue;
156
+ const r = rows.get(m[1]) || { worker: m[1], sessions: 0, tokens: 0 };
157
+ r.sessions += 1; r.tokens += Number(m[2]);
158
+ rows.set(m[1], r);
159
+ }
160
+ } catch {}
161
+ return [...rows.values()].map((r) => ({ ...r, cost: Math.round((r.tokens / 1e6) * PRICE_PER_MILLION * 100) / 100 })).sort((a, b) => b.tokens - a.tokens);
162
+ }
163
+ function readTail(f, n = 200) { try { const t = fs.readFileSync(f, "utf8"); return t.split("\n").slice(-n).join("\n"); } catch { return ""; } }
164
+ function liveRead() { try { const s = JSON.parse(fs.readFileSync(LIVE_FILE, "utf8")); s.inbox = s.inbox || []; return s; } catch { return { status: "idle", worker: null, startedAt: null, speakers: [], orders: [], inbox: [] }; } }
165
+ function liveWrite(s) { fs.mkdirSync(DATA_DIR, { recursive: true }); fs.writeFileSync(LIVE_FILE, JSON.stringify(s, null, 2)); }
166
+ function metrics() {
167
+ if (db) {
168
+ const q = (sql) => db.prepare(sql).get().n;
169
+ return {
170
+ reports: q("SELECT COUNT(*) n FROM reports"),
171
+ reportsWeek: q("SELECT COUNT(*) n FROM reports WHERE ts >= datetime('now','-7 days')"),
172
+ feedback: q("SELECT COUNT(*) n FROM feedback"),
173
+ feedbackWeek: q("SELECT COUNT(*) n FROM feedback WHERE ts >= datetime('now','-7 days')"),
174
+ workers: q("SELECT COUNT(DISTINCT worker) n FROM (SELECT worker FROM reports UNION SELECT worker FROM feedback)"),
175
+ lastReport: (db.prepare("SELECT ts FROM reports ORDER BY id DESC LIMIT 1").get() || {}).ts || null,
176
+ lastFeedback: (db.prepare("SELECT ts FROM feedback ORDER BY id DESC LIMIT 1").get() || {}).ts || null,
177
+ };
178
+ }
179
+ const parse = (file) => {
180
+ try {
181
+ const out = [];
182
+ for (const line of fs.readFileSync(file, "utf8").split("\n")) {
183
+ const m = /^## (\d{4}-\d{2}-\d{2}T[\d:.]+Z) — (.+)$/.exec(line);
184
+ if (m) out.push({ date: m[1], desc: m[2] });
185
+ }
186
+ return out;
187
+ } catch { return []; }
188
+ };
189
+ const reps = parse(REPORTS_FILE), fb = parse(FEEDBACK_FILE);
190
+ const week = 7 * 24 * 3600e3, now = Date.now();
191
+ const inWeek = (e) => now - new Date(e.date).getTime() < week;
192
+ const workers = new Set();
193
+ fb.forEach((e) => { const m = /from (\S+)/.exec(e.desc); if (m) workers.add(m[1]); });
194
+ reps.forEach((e) => { const m = /by (\S+)/.exec(e.desc); if (m) workers.add(m[1]); });
195
+ return {
196
+ reports: reps.length, reportsWeek: reps.filter(inWeek).length,
197
+ feedback: fb.length, feedbackWeek: fb.filter(inWeek).length,
198
+ workers: workers.size,
199
+ lastReport: reps.length ? reps[reps.length - 1].date : null,
200
+ lastFeedback: fb.length ? fb[fb.length - 1].date : null,
201
+ };
202
+ }
203
+
204
+ // ---------- sessions ----------
205
+ const sessions = new Map();
206
+ function issue(name, role, label) { const token = crypto.randomBytes(24).toString("hex"); sessions.set(token, { name, role, label: label || name, expires: Date.now() + SESSION_TTL }); return token; }
207
+ function auth(req) {
208
+ const h = req.headers.authorization || "";
209
+ const m = /^Bearer (.+)$/.exec(h);
210
+ let token = m ? m[1] : null;
211
+ if (!token && req.headers.cookie) {
212
+ const c = req.headers.cookie.split(";").find((x) => x.trim().startsWith("dsh_sup="));
213
+ if (c) token = c.split("=")[1];
214
+ }
215
+ if (!token) return null;
216
+ const s = sessions.get(token);
217
+ if (!s) return null;
218
+ if (s.expires < Date.now()) { sessions.delete(token); return null; }
219
+ return s;
220
+ }
221
+ function ownerOnly(res, req) {
222
+ const s = auth(req);
223
+ if (!s || s.role !== "owner") { res.writeHead(403, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "owner only" })); return false; }
224
+ return true;
225
+ }
226
+ function managerOnly(res, req) {
227
+ const s = auth(req);
228
+ if (!s || (s.role !== "owner" && s.role !== "lead")) { res.writeHead(403, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "manager only" })); return false; }
229
+ return true;
230
+ }
231
+ function json(req) {
232
+ return new Promise((ok) => {
233
+ let b = "";
234
+ req.on("data", (d) => { b += d; if (b.length > 1e6) req.destroy(); });
235
+ req.on("end", () => { try { ok(b ? JSON.parse(b) : {}); } catch { ok({}); } });
236
+ });
237
+ }
238
+ const CORS = {
239
+ "Access-Control-Allow-Origin": "*",
240
+ "Access-Control-Allow-Methods": "GET,POST,OPTIONS",
241
+ "Access-Control-Allow-Headers": "Content-Type, Authorization",
242
+ };
243
+ function send(res, code, obj) { res.writeHead(code, { "Content-Type": "application/json; charset=utf-8", ...CORS }); res.end(JSON.stringify(obj)); }
244
+
245
+ // ---------- API ----------
246
+ async function api(req, res, pathname) {
247
+ if (req.method === "OPTIONS") { res.writeHead(204, CORS); return res.end(); }
248
+ if (pathname === "/api/login" && req.method === "POST") {
249
+ const b = await json(req);
250
+ const users = loadUsers();
251
+ const u = users[b.user];
252
+ if (!u || hashPw(String(b.password || ""), u.salt) !== u.hash) return send(res, 401, { error: "bad credentials" });
253
+ const token = issue(b.user, u.role, u.label);
254
+ res.setHeader("Set-Cookie", "dsh_sup=" + token + "; Path=/; HttpOnly; SameSite=Lax");
255
+ return send(res, 200, { token, name: b.user, role: u.role });
256
+ }
257
+ if (pathname === "/api/logout" && req.method === "POST") { const s = auth(req); if (s) for (const [t, v] of sessions) if (v.name === s.name) sessions.delete(t); return send(res, 200, { ok: true }); }
258
+ if (pathname === "/api/me") { const s = auth(req); return send(res, 200, s ? { name: s.name, role: s.role, label: s.label || s.name } : { error: "unauthorized" }); }
259
+ if (pathname === "/api/feedback" && req.method === "POST") {
260
+ const b = await json(req);
261
+ appendFeedback(b.worker, b.role, b.category, b.text);
262
+ return send(res, 200, { ok: true });
263
+ }
264
+ if (pathname === "/api/feedback" && req.method === "GET") { if (!managerOnly(res, req)) return; return send(res, 200, { content: readFeedback(200) }); }
265
+ if (pathname === "/api/report" && req.method === "POST") {
266
+ const s = auth(req); if (!s) return send(res, 401, { error: "unauthorized" });
267
+ const b = await json(req);
268
+ appendReport(s.name, b.text);
269
+ return send(res, 200, { ok: true });
270
+ }
271
+ if (pathname === "/api/reports" && req.method === "GET") { if (!managerOnly(res, req)) return; return send(res, 200, { content: readReports(200) }); }
272
+ if (pathname === "/api/metrics" && req.method === "GET") { if (!managerOnly(res, req)) return; return send(res, 200, metrics()); }
273
+ // Usage: workers/agents report token counts per session; managers see cost per worker.
274
+ if (pathname === "/api/usage" && req.method === "POST") {
275
+ const b = await json(req);
276
+ appendUsage(b.worker, b.tokens, b.model);
277
+ return send(res, 200, { ok: true });
278
+ }
279
+ if (pathname === "/api/usage" && req.method === "GET") {
280
+ if (!managerOnly(res, req)) return;
281
+ return send(res, 200, { pricePerMillion: PRICE_PER_MILLION, rows: usageRows() });
282
+ }
283
+ // Public wallboard feed: aggregated numbers only, no secrets.
284
+ if (pathname === "/api/wall" && req.method === "GET") {
285
+ const m = metrics();
286
+ return send(res, 200, { ...m, feedbackTail: readFeedback(60) });
287
+ }
288
+ // Live conversation flow: worker screens, per-person summary, orders to
289
+ // the kitchen and the central system (the supervisor itself).
290
+ if (pathname === "/api/live" && req.method === "GET") {
291
+ return send(res, 200, liveRead());
292
+ }
293
+ // The active sector pack: one dashboard skeleton, many business skins.
294
+ if (pathname === "/api/sector" && req.method === "GET") {
295
+ try { return send(res, 200, JSON.parse(fs.readFileSync(SECTOR_FILE, "utf8"))); }
296
+ catch { return send(res, 200, { id: "generic", tableLabel: { en: "Table", es: "Mesa" }, ordersTarget: { en: "Orders", es: "Pedidos" }, entity: { en: "person", es: "persona" }, fields: [] }); }
297
+ }
298
+ if (pathname === "/api/sector" && req.method === "POST") {
299
+ if (!ownerOnly(res, req)) return;
300
+ const b = await json(req);
301
+ fs.mkdirSync(DATA_DIR, { recursive: true });
302
+ fs.writeFileSync(SECTOR_FILE, JSON.stringify(b, null, 2));
303
+ return send(res, 200, { ok: true });
304
+ }
305
+ if (pathname === "/api/sector-presets" && req.method === "GET") {
306
+ const dir = process.env.DSH_SECTOR_PACKS_DIR || path.join(ROOT, "docs", "sector-packs");
307
+ const out = [];
308
+ try {
309
+ for (const f of fs.readdirSync(dir).filter((x) => x.endsWith(".json")).sort()) {
310
+ try { out.push(JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"))); } catch {}
311
+ }
312
+ } catch {}
313
+ return send(res, 200, { presets: out });
314
+ }
315
+ if (pathname === "/api/live/start" && req.method === "POST") {
316
+ const b = await json(req);
317
+ liveWrite({ status: "listening", worker: b.worker || "worker", table: b.table || null, fields: b.fields || {}, startedAt: ts(), speakers: [], orders: [], inbox: [] });
318
+ return send(res, 200, { ok: true });
319
+ }
320
+ if (pathname === "/api/live/summary" && req.method === "POST") {
321
+ const b = await json(req);
322
+ const s = liveRead();
323
+ s.status = "summary";
324
+ s.worker = b.worker || s.worker;
325
+ s.speakers = (b.speakers || []).map((p, i) => ({
326
+ id: "p" + (i + 1),
327
+ name: p.name || ("Person " + (i + 1)),
328
+ requests: (p.requests || []).map((r, j) => ({
329
+ id: "p" + (i + 1) + "-r" + (j + 1),
330
+ text: String(r.text || ""),
331
+ tier: r.tier || "GREEN",
332
+ status: "pending",
333
+ })),
334
+ }));
335
+ liveWrite(s);
336
+ return send(res, 200, { ok: true });
337
+ }
338
+ if (pathname === "/api/live/confirm" && req.method === "POST") {
339
+ const b = await json(req);
340
+ const s = liveRead();
341
+ let ok = false;
342
+ for (const p of s.speakers) for (const r of p.requests) {
343
+ if (r.id === b.requestId && r.status !== "confirmed") {
344
+ r.status = "confirmed"; ok = true;
345
+ s.orders.push({ id: "o" + (s.orders.length + 1), ts: ts(), table: s.table, person: p.name, request: r.text, tier: r.tier, status: "sent" });
346
+ }
347
+ }
348
+ liveWrite(s);
349
+ return send(res, 200, { ok });
350
+ }
351
+ if (pathname === "/api/live/order" && req.method === "POST") {
352
+ const b = await json(req);
353
+ const s = liveRead();
354
+ const o = s.orders.find((x) => x.id === b.orderId);
355
+ if (o) o.status = b.status || "sent";
356
+ liveWrite(s);
357
+ return send(res, 200, { ok: !!o });
358
+ }
359
+ if (pathname === "/api/live/close" && req.method === "POST") {
360
+ liveWrite({ status: "idle", worker: null, startedAt: null, speakers: [], orders: [], inbox: [] });
361
+ return send(res, 200, { ok: true });
362
+ }
363
+ // Speech/text from the worker's device → the always-connected AI agent.
364
+ if (pathname === "/api/live/transcript" && req.method === "POST") {
365
+ const b = await json(req);
366
+ const s = liveRead();
367
+ s.inbox = s.inbox || [];
368
+ s.inbox.push({ id: "t" + (s.inbox.length + 1), ts: ts(), worker: b.worker || s.worker, text: String(b.text || "").trim() });
369
+ liveWrite(s);
370
+ return send(res, 200, { ok: true });
371
+ }
372
+ if (pathname === "/api/live/inbox" && req.method === "GET") {
373
+ const s = liveRead();
374
+ const items = (s.inbox || []).filter((x) => !x.acked);
375
+ if (items.length) { s.inbox.forEach((x) => { x.acked = true; }); liveWrite(s); }
376
+ return send(res, 200, { items });
377
+ }
378
+ if (pathname === "/api/decisions" && req.method === "GET") { if (!ownerOnly(res, req)) return; return send(res, 200, { content: readDecisions() }); }
379
+ if (pathname === "/api/decisions" && req.method === "POST") { if (!ownerOnly(res, req)) return; const b = await json(req); writeDecisions(b.content); return send(res, 200, { ok: true }); }
380
+ if (pathname === "/api/users" && req.method === "GET") { if (!ownerOnly(res, req)) return; const u = loadUsers(); return send(res, 200, { users: Object.entries(u).map(([name, v]) => ({ name, role: v.role, label: v.label || name })) }); }
381
+ if (pathname === "/api/users" && req.method === "POST") {
382
+ if (!ownerOnly(res, req)) return;
383
+ const b = await json(req);
384
+ if (!b.name || !b.password) return send(res, 400, { error: "name and password required" });
385
+ const u = loadUsers();
386
+ if (u[b.name]) return send(res, 409, { error: "user exists" });
387
+ const salt = crypto.randomBytes(16).toString("hex");
388
+ const role = b.role === "owner" ? "owner" : (b.role === "lead" ? "lead" : "employee");
389
+ const label = String(b.label || b.name);
390
+ if (db) db.prepare("INSERT INTO users (name,salt,hash,role,label,created) VALUES (?,?,?,?,?,?)").run(b.name, salt, hashPw(String(b.password), salt), role, label, ts());
391
+ else { u[b.name] = { salt, hash: hashPw(String(b.password), salt), role, label }; saveUsers(u); }
392
+ return send(res, 200, { ok: true });
393
+ }
394
+ return send(res, 404, { error: "not found" });
395
+ }
396
+
397
+ // ---------- static ----------
398
+ const MIME = { ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png", ".jpg": "image/jpeg", ".json": "application/json", ".webmanifest": "application/manifest+json", ".md": "text/plain; charset=utf-8" };
399
+ function serveStatic(req, res, pathname) {
400
+ let p;
401
+ if (pathname === "/" || pathname === "/dashboard" || pathname === "/dashboard.html") p = path.join(__dirname, "dashboard.html");
402
+ else if (pathname === "/panel" || pathname === "/panel.html") p = path.join(UI_DIR, "panel.html");
403
+ else { const rel = pathname.replace(/^\/+/, ""); p = path.join(UI_DIR, rel); if (!p.startsWith(UI_DIR)) { res.writeHead(403); return res.end(); } }
404
+ fs.readFile(p, (err, data) => {
405
+ if (err) { res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); return res.end("not found"); }
406
+ res.writeHead(200, { "Content-Type": MIME[path.extname(p)] || "application/octet-stream" });
407
+ res.end(data);
408
+ });
409
+ }
410
+
411
+ const handler = (req, res) => {
412
+ const pathname = new URL(req.url, "http://x").pathname;
413
+ if (pathname.startsWith("/api/")) return api(req, res, pathname);
414
+ serveStatic(req, res, pathname);
415
+ };
416
+
417
+ http.createServer(handler).listen(PORT, HOST, () => {
418
+ console.log("J.Franco supervisor running at http://" + HOST + ":" + PORT + (USE_DB ? " (sqlite)" : " (files)"));
419
+ console.log("Dashboard: http://" + HOST + ":" + PORT + "/dashboard Panel: http://" + HOST + ":" + PORT + "/panel");
420
+ });
421
+ if (HTTPS_ON) {
422
+ if (!fs.existsSync(TLS_KEY) || !fs.existsSync(TLS_CERT)) {
423
+ console.error("TLS enabled but certificates are missing. Run: sh tools/make-cert.sh (self-signed) or sh tools/letsencrypt.sh <domain> <email> (real).");
424
+ process.exit(1);
425
+ }
426
+ https.createServer({ key: fs.readFileSync(TLS_KEY), cert: fs.readFileSync(TLS_CERT) }, handler).listen(HTTPS_PORT, HOST, () => {
427
+ console.log("HTTPS: https://" + HOST + ":" + HTTPS_PORT + " (cert: " + TLS_CERT + ")");
428
+ });
429
+ }