@javierfrancoleal/dsh-supervisor 1.0.14 → 1.0.16

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/dashboard.html CHANGED
@@ -204,7 +204,7 @@ body.wall pre{max-height:40vh}
204
204
  <table id="usersTable"><thead><tr><th id="thUser">User</th><th id="thRole">Role</th></tr></thead><tbody></tbody></table>
205
205
  <h2 style="margin-top:14px" id="addWorkerTitle">Add worker</h2>
206
206
  <div id="suggestUsers" class="row"></div>
207
- <input id="newUser" placeholder="User name"><input id="newPass" type="password" placeholder="Password">
207
+ <input id="newUser" placeholder="User name"><input id="newPass" type="password" placeholder="Password" minlength="8">
208
208
  <input id="newLabel" placeholder="Role label (e.g. Cook)">
209
209
  <select id="newRole"><option id="optEmployee" value="employee">employee</option><option id="optLead" value="lead">lead</option><option id="optOwner" value="owner">owner</option></select>
210
210
  <button class="btn" id="addUserBtn">Add worker</button>
@@ -418,6 +418,7 @@ function applyLang(){
418
418
  }
419
419
  function pill(l){for(const host of ["langs","footLangs"]){const b=document.createElement("button");b.className="pill";b.dataset.l=l;b.textContent=LANG[l];b.onclick=()=>{cur=l;applyLang();};$(host).appendChild(b);}}
420
420
  for(const l of Object.keys(LANG))pill(l);
421
+ try{const q=new URLSearchParams(location.search);const ql=q.get("lang");if(ql&&S[ql])cur=ql;}catch(e){}
421
422
  try{const saved=localStorage.getItem("dsh-lang");if(saved&&S[saved])cur=saved;}catch(e){}
422
423
  applyLang();
423
424
  function applyPack(){if(!PACK)return;$("tableInput").placeholder=(PACK.tableLabel&&PACK.tableLabel[cur])||"Table";const ot=(PACK.ordersTarget&&PACK.ordersTarget[cur])||t("ordersTitle");$("ordersTitle").textContent=ot;$("leadOrdersTitle").textContent=ot;$("ordersTitle2").textContent=ot;}
@@ -427,18 +428,19 @@ const LVL={employee:"lvlEmployee",lead:"lvlLead",owner:"lvlOwner"};
427
428
  function setWho(){const w=$("who");w.textContent=me?(me.label||me.name):"";const c=$("idChip");if(me){c.style.display="inline-block";c.textContent=me.name.slice(0,1).toUpperCase()+" · "+(me.label||me.name)+" · "+t(LVL[me.role]||"lvlEmployee");}else{c.style.display="none";}$("logoutBtn").style.display=me?"inline-block":"none";}
428
429
  function toast(m){const el=$("toast");el.textContent=m;el.classList.add("show");clearTimeout(el.__t);el.__t=setTimeout(()=>el.classList.remove("show"),2200);}
429
430
  function skel(n){let out="";for(let i=0;i<n;i++)out+='<div class="skel" style="height:38px;margin:8px 0"></div>';return out;}
431
+ const esc=(s)=>String(s==null?"":s).replace(/[&<>"']/g,(c)=>({"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;"}[c]));
430
432
  function empty(ico,txt){return '<div class="empty"><span class="ico">'+ico+'</span>'+txt+'</div>';}
431
433
  function showMsg(x){$("msg").textContent=x;toast(x);setTimeout(()=>$("msg").textContent="",2500);}
432
434
  function countUp(el,target,dur){dur=dur||650;const t0=performance.now();const step=(now)=>{const p=Math.min(1,(now-t0)/dur);el.textContent=Math.round(target*p).toLocaleString();if(p<1)requestAnimationFrame(step);};requestAnimationFrame(step);}
433
435
  async function loadMetrics(into){into.innerHTML=skel(2);const m=await api("/api/metrics");into.innerHTML="";const cards=[[t("reports"),m.reports],[t("week"),m.reportsWeek],[t("feedback"),m.feedback],[t("workersActive"),m.workers]];for(const [l,v] of cards){const d=document.createElement("div");d.className="metric";d.innerHTML='<div class="v">–</div><div class="l">'+l+'</div>';into.appendChild(d);if(typeof v==="number")countUp(d.querySelector(".v"),v);}}
434
436
  async function loadUsage(){const tb=$("usageTable")?$("usageTable").querySelector("tbody"):null;if(!tb)return;tb.innerHTML='<tr><td colspan="4">'+skel(2)+'</td></tr>';const r=await api("/api/usage");tb.innerHTML="";
435
437
  if(!(r.rows||[]).length){tb.innerHTML='<tr><td colspan="4">'+empty("📊",t("noUsage"))+'</td></tr>';return;}
436
- (r.rows||[]).forEach(x=>{const tr=document.createElement("tr");tr.innerHTML="<td>"+x.worker+"</td><td>"+x.sessions+"</td><td>"+(x.tokens||0).toLocaleString()+"</td><td>$"+(x.cost||0).toFixed(2)+"</td>";tb.appendChild(tr);});}
438
+ (r.rows||[]).forEach(x=>{const tr=document.createElement("tr");tr.innerHTML="<td>"+esc(x.worker)+"</td><td>"+x.sessions+"</td><td>"+(x.tokens||0).toLocaleString()+"</td><td>$"+(x.cost||0).toFixed(2)+"</td>";tb.appendChild(tr);});}
437
439
  function orderRow(o,canUpdate){
438
440
  const d=document.createElement("div");d.className="order";
439
441
  const stat=document.createElement("span");stat.className="stat";const ICON={sent:"📤",prep:"🍳",ready:"✅",delivered:"🛎️"};
440
442
  stat.textContent=(ICON[o.status]||"📤")+" "+t(o.status||"sent");
441
- d.innerHTML="<b>"+(o.table?((PACK&&PACK.tableLabel&&PACK.tableLabel[cur]?PACK.tableLabel[cur]:"Table")+" "+o.table+" · "):"")+o.person+"</b>";
443
+ d.innerHTML="<b>"+(o.table?((PACK&&PACK.tableLabel&&PACK.tableLabel[cur]?PACK.tableLabel[cur]:"Table")+" "+o.table+" · "):"")+esc(o.person)+"</b>";
442
444
  const txt=document.createElement("span");txt.className="txt";txt.textContent=o.request;
443
445
  d.appendChild(txt);d.appendChild(stat);
444
446
  if(canUpdate&&o.status!=="delivered"){
@@ -486,10 +488,10 @@ function renderSummary(live){
486
488
  for(const p of live.speakers){
487
489
  const d=document.createElement("div");d.className="person";
488
490
  const done=p.requests.filter(r=>r.status==="confirmed").length;
489
- let h='<div class="pname">'+t("person")+" "+(p.id.replace("p",""))+" — "+p.name+'<span class="pcnt">('+done+'/'+p.requests.length+')</span></div>';
491
+ let h='<div class="pname">'+t("person")+" "+(p.id.replace("p",""))+" — "+esc(p.name)+'<span class="pcnt">('+done+'/'+p.requests.length+')</span></div>';
490
492
  for(const r of p.requests){
491
493
  const confirmed=r.status==="confirmed";
492
- h+='<div class="req"><span class="txt">'+r.text+'</span>'+
494
+ h+='<div class="req"><span class="txt">'+esc(r.text)+'</span>'+
493
495
  '<span class="tier tier-'+r.tier+'">'+t("tier"+r.tier)+'</span>'+
494
496
  '<button class="confirmBtn'+(confirmed?" done":"")+'" data-req="'+r.id+'">'+(confirmed?t("confirmed"):t("confirm"))+'</button></div>'+
495
497
  (confirmed?'<div class="sentline">✓ '+t("sentNote")+'</div>':'');
@@ -534,9 +536,9 @@ $("loginBtn").onclick=async()=>{
534
536
  function logout(){api("/api/logout",{});token=null;me=null;sessionStorage.clear();if(livePoll)clearInterval(livePoll);if(kitchenPoll)clearInterval(kitchenPoll);setWho();show("loginView");}
535
537
  $("logoutBtn").onclick=logout;
536
538
  const sb=$("switchBtn");if(sb)sb.onclick=logout;
537
- async function loadUsers(){const tb=$("usersTable").querySelector("tbody");tb.innerHTML='<tr><td colspan="2">'+skel(2)+'</td></tr>';const r=await api("/api/users");tb.innerHTML="";(r.users||[]).forEach(u=>{const tr=document.createElement("tr");tr.innerHTML="<td>"+u.name+"</td><td>"+(u.label||u.name)+" · "+t(LVL[u.role]||"lvlEmployee")+"</td>";tb.appendChild(tr);});}
539
+ async function loadUsers(){const tb=$("usersTable").querySelector("tbody");tb.innerHTML='<tr><td colspan="2">'+skel(2)+'</td></tr>';const r=await api("/api/users");tb.innerHTML="";(r.users||[]).forEach(u=>{const tr=document.createElement("tr");tr.innerHTML="<td>"+esc(u.name)+"</td><td>"+esc(u.label||u.name)+" · "+t(LVL[u.role]||"lvlEmployee")+"</td>";tb.appendChild(tr);});}
538
540
  $("addUserBtn").onclick=async()=>{const r=await api("/api/users",{name:$("newUser").value,password:$("newPass").value,role:$("newRole").value,label:$("newLabel").value});showMsg(r.error?"Error: "+r.error:t("workerAdded"));if(!r.error){loadUsers();$("newUser").value=$("newPass").value="";}};
539
- async function loadField(){$("reports").innerHTML=skel(2);$("feedback").innerHTML=skel(1);$("trail").innerHTML=skel(1);const rc=(await api("/api/reports")).content;if(rc){$("reports").innerHTML="<pre>"+rc+"</pre>";}else{$("reports").innerHTML=empty("📋",t("noReports"));}const fc=(await api("/api/feedback")).content;if(fc){$("feedback").innerHTML="<pre>"+fc+"</pre>";}else{$("feedback").innerHTML=empty("💬",t("feedbackQueue"));}const live=await api("/api/live");const ib=(live.inbox||[]).map(x=>"[•] "+x.worker+": "+x.text).join("\n");if(ib){$("trail").innerHTML="<pre>"+ib+"</pre>";}else{$("trail").innerHTML=empty("🗒️",t("conversationTrail"));}}
541
+ async function loadField(){$("reports").innerHTML=skel(2);$("feedback").innerHTML=skel(1);$("trail").innerHTML=skel(1);const rc=(await api("/api/reports")).content;if(rc){$("reports").innerHTML="<pre>"+esc(rc)+"</pre>";}else{$("reports").innerHTML=empty("📋",t("noReports"));}const fc=(await api("/api/feedback")).content;if(fc){$("feedback").innerHTML="<pre>"+esc(fc)+"</pre>";}else{$("feedback").innerHTML=empty("💬",t("feedbackQueue"));}const live=await api("/api/live");const ib=(live.inbox||[]).map(x=>"[•] "+esc(x.worker)+": "+esc(x.text)).join("\n");if(ib){$("trail").innerHTML="<pre>"+ib+"</pre>";}else{$("trail").innerHTML=empty("🗒️",t("conversationTrail"));}}
540
542
  async function loadLead(){$("leadReports").textContent=(await api("/api/reports")).content||"(empty)";$("leadFeedback").textContent=(await api("/api/feedback")).content||"(empty)";}
541
543
  $("refreshReports").onclick=loadField;$("leadRefresh").onclick=loadLead;
542
544
  const ar=$("autoRefresh");
@@ -571,7 +573,7 @@ $("empFbBtn").onclick=async()=>{const r=await api("/api/feedback",{worker:me.nam
571
573
  const st=sessionStorage.getItem("dsh-sup-token");
572
574
  if(st){token=st;me=JSON.parse(sessionStorage.getItem("dsh-sup-me")||"null");(async()=>{const r=await api("/api/me");if(!r||!r.name){logout();return;}enter();})();}
573
575
  else setWho();
574
- async function wallTick(){try{const r=await (await fetch("/api/wall")).json();const cards=[[t("week"),r.reportsWeek],[t("ordersTitle"),r.orders],[t("feedback"),r.feedback],[t("workersActive"),r.workers],[t("reportsTotal"),r.reports]];$("wallCards").innerHTML=cards.map(c=>'<div class="wall-card"><div class="v">'+(c[1]===undefined?"–":c[1])+'</div><div class="l">'+c[0]+'</div></div>').join("");const ticks=[...(r.ordersTail||[]).map(x=>"🔔 "+x),...(r.feedbackTail||"").split("\n").filter(Boolean).slice(-4)];$("wallFeed").innerHTML=ticks.length?ticks.map(x=>'<div class="tick">'+x+'</div>').join(""):'<div class="tick dim">'+t("noActivity")+'</div>';try{const s=await (await fetch("/api/sector")).json();$("wallSector").textContent=(s&&s.id)?s.id:"";}catch(e){}}catch(e){$("wallFeed").innerHTML='<div class="tick dim">'+t("cannotReach")+'</div>';}}
576
+ async function wallTick(){try{const r=await (await fetch("/api/wall")).json();const cards=[[t("week"),r.reportsWeek],[t("ordersTitle"),r.orders],[t("feedback"),r.feedback],[t("workersActive"),r.workers],[t("reportsTotal"),r.reports]];$("wallCards").innerHTML=cards.map(c=>'<div class="wall-card"><div class="v">'+(c[1]===undefined?"–":c[1])+'</div><div class="l">'+c[0]+'</div></div>').join("");const ticks=[...(r.ordersTail||[]).map(x=>"🔔 "+esc(x)),...(r.feedbackTail||"").split("\n").filter(Boolean).slice(-4).map(esc)];$("wallFeed").innerHTML=ticks.length?ticks.map(x=>'<div class="tick">'+x+'</div>').join(""):'<div class="tick dim">'+t("noActivity")+'</div>';try{const s=await (await fetch("/api/sector")).json();$("wallSector").textContent=(s&&s.id)?s.id:"";}catch(e){}}catch(e){$("wallFeed").innerHTML='<div class="tick dim">'+t("cannotReach")+'</div>';}}
575
577
  function clockTick(){const d=new Date();$("wallClock").textContent=d.toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"});$("wallDate").textContent=d.toLocaleDateString([],{weekday:"short",day:"numeric",month:"short"});}
576
578
  if(location.search.indexOf("wall=1")>=0){
577
579
  document.body.classList.add("wall");show("wallView");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@javierfrancoleal/dsh-supervisor",
3
- "version": "1.0.14",
3
+ "version": "1.0.16",
4
4
  "description": "The Veilleux central control plane of the J.Franco agentic workflow suite: user accounts, the live conversation flow (listen → per-person summary → confirm → orders), telemetry, and the universal sector dashboard with 10 languages. Created by J.Franco.",
5
5
  "author": "J.Franco",
6
6
  "license": "MIT",
package/server.js CHANGED
@@ -46,6 +46,18 @@ const PRICE_PER_MILLION = Number(process.env.DSH_PRICE_PER_MILLION || 0.28);
46
46
  const TLS_KEY = process.env.DSH_TLS_KEY || path.join(DATA_DIR, "certs", "key.pem");
47
47
  const TLS_CERT = process.env.DSH_TLS_CERT || path.join(DATA_DIR, "certs", "cert.pem");
48
48
  const SESSION_TTL = 8 * 60 * 60 * 1000;
49
+ // Login brute-force protection: per-IP escalating lockout.
50
+ const loginAttempts = new Map();
51
+ const MAX_FAILS = 8, BASE_LOCK_MS = 60 * 1000;
52
+ function loginBlocked(ip) {
53
+ const a = loginAttempts.get(ip);
54
+ if (!a) return null;
55
+ if (a.lockUntil && Date.now() < a.lockUntil) return Math.ceil((a.lockUntil - Date.now()) / 1000);
56
+ if (a.fails >= MAX_FAILS) { a.lockUntil = Date.now() + BASE_LOCK_MS * Math.pow(2, a.lockCount || 0); a.lockCount = (a.lockCount || 0) + 1; a.fails = 0; return Math.ceil((a.lockUntil - Date.now()) / 1000); }
57
+ return null;
58
+ }
59
+ function loginFail(ip) { const a = loginAttempts.get(ip) || { fails: 0 }; a.fails += 1; loginAttempts.set(ip, a); }
60
+ function loginOk(ip) { loginAttempts.delete(ip); }
49
61
 
50
62
  // ---------- SQLite storage (--db) ----------
51
63
  let db = null;
@@ -73,6 +85,7 @@ if (ARGS[0] === "init") {
73
85
  fs.mkdirSync(DATA_DIR, { recursive: true });
74
86
  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
87
  const add = (name, pw, role, label) => {
88
+ if (String(pw).length < 8) { console.error("init: password for " + name + " must be at least 8 characters"); process.exit(1); }
76
89
  const salt = crypto.randomBytes(16).toString("hex");
77
90
  const hash = crypto.scryptSync(pw, salt, 64).toString("hex");
78
91
  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());
@@ -240,26 +253,30 @@ const CORS = {
240
253
  "Access-Control-Allow-Methods": "GET,POST,OPTIONS",
241
254
  "Access-Control-Allow-Headers": "Content-Type, Authorization",
242
255
  };
243
- function send(res, code, obj) { res.writeHead(code, { "Content-Type": "application/json; charset=utf-8", ...CORS }); res.end(JSON.stringify(obj)); }
256
+ function send(res, code, obj, pub) { const h = { "Content-Type": "application/json; charset=utf-8" }; if (pub) Object.assign(h, CORS); res.writeHead(code, h); res.end(JSON.stringify(obj)); }
244
257
 
245
258
  // ---------- API ----------
246
259
  async function api(req, res, pathname) {
247
260
  if (req.method === "OPTIONS") { res.writeHead(204, CORS); return res.end(); }
248
261
  if (pathname === "/api/login" && req.method === "POST") {
262
+ const ip = (req.headers["x-forwarded-for"] || req.socket.remoteAddress || "?").toString().split(",")[0].trim();
263
+ const wait = loginBlocked(ip);
264
+ if (wait) return send(res, 429, { error: "too many attempts — retry in " + wait + " s" });
249
265
  const b = await json(req);
250
266
  const users = loadUsers();
251
267
  const u = users[b.user];
252
- if (!u || hashPw(String(b.password || ""), u.salt) !== u.hash) return send(res, 401, { error: "bad credentials" });
268
+ if (!u || hashPw(String(b.password || ""), u.salt) !== u.hash) { loginFail(ip); return send(res, 401, { error: "bad credentials" }); }
269
+ loginOk(ip);
253
270
  const token = issue(b.user, u.role, u.label);
254
271
  res.setHeader("Set-Cookie", "dsh_sup=" + token + "; Path=/; HttpOnly; SameSite=Lax");
255
- return send(res, 200, { token, name: b.user, role: u.role });
272
+ return send(res, 200, { token, name: b.user, role: u.role }, true);
256
273
  }
257
274
  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
275
  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
276
  if (pathname === "/api/feedback" && req.method === "POST") {
260
277
  const b = await json(req);
261
278
  appendFeedback(b.worker, b.role, b.category, b.text);
262
- return send(res, 200, { ok: true });
279
+ return send(res, 200, { ok: true }, true);
263
280
  }
264
281
  if (pathname === "/api/feedback" && req.method === "GET") { if (!managerOnly(res, req)) return; return send(res, 200, { content: readFeedback(200) }); }
265
282
  if (pathname === "/api/report" && req.method === "POST") {
@@ -306,7 +323,7 @@ async function api(req, res, pathname) {
306
323
  if (pathname === "/api/wall" && req.method === "GET") {
307
324
  const m = metrics();
308
325
  const lv = liveRead();
309
- return send(res, 200, { ...m, orders: (lv.orders || []).length, ordersTail: (lv.orders || []).slice(-3).map((o) => (o.table ? "M" + o.table + " · " : "") + o.person + " — " + o.request), feedbackTail: readFeedback(60) });
326
+ return send(res, 200, { ...m, orders: (lv.orders || []).length, ordersTail: (lv.orders || []).slice(-3).map((o) => (o.table ? "M" + o.table + " · " : "") + o.person + " — " + o.request), feedbackTail: readFeedback(60) }, true);
310
327
  }
311
328
  // Live conversation flow: worker screens, per-person summary, orders to
312
329
  // the kitchen and the central system (the supervisor itself).
@@ -405,6 +422,7 @@ async function api(req, res, pathname) {
405
422
  if (!ownerOnly(res, req)) return;
406
423
  const b = await json(req);
407
424
  if (!b.name || !b.password) return send(res, 400, { error: "name and password required" });
425
+ if (String(b.password).length < 8) return send(res, 400, { error: "password must be at least 8 characters" });
408
426
  const u = loadUsers();
409
427
  if (u[b.name]) return send(res, 409, { error: "user exists" });
410
428
  const salt = crypto.randomBytes(16).toString("hex");