@inetafrica/open-claudia 3.5.16 → 3.5.18

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.
Files changed (46) hide show
  1. package/.env.example +10 -0
  2. package/CHANGELOG.md +53 -0
  3. package/README.md +4 -0
  4. package/bin/cli.js +1 -1
  5. package/bin/task.js +51 -0
  6. package/bot.js +26 -0
  7. package/channels/kazee/adapter.js +130 -21
  8. package/channels/telegram/adapter.js +4 -2
  9. package/core/actions.js +8 -0
  10. package/core/config.js +22 -1
  11. package/core/cost-report.js +3 -0
  12. package/core/cron-schedule.js +59 -0
  13. package/core/dream.js +141 -34
  14. package/core/guardrail.js +5 -0
  15. package/core/handlers.js +78 -17
  16. package/core/lessons.js +11 -5
  17. package/core/limit-pickup.js +313 -0
  18. package/core/loopback.js +29 -0
  19. package/core/providers/claude-events.js +79 -0
  20. package/core/providers/codex.js +48 -0
  21. package/core/providers/events.js +4 -1
  22. package/core/relationship.js +1 -1
  23. package/core/router.js +16 -1
  24. package/core/run-context.js +3 -0
  25. package/core/runner.js +440 -14
  26. package/core/safe-filename.js +47 -0
  27. package/core/scheduler.js +170 -38
  28. package/core/state.js +91 -0
  29. package/core/system-prompt.js +89 -16
  30. package/core/tasks.js +208 -6
  31. package/core/turn-journal.js +508 -0
  32. package/core/usage-limits.js +321 -0
  33. package/core/utility-agent.js +17 -1
  34. package/package.json +4 -4
  35. package/test-fixtures/fake-agent-cli.js +26 -2
  36. package/test-guardrail.js +19 -0
  37. package/test-kazee-context-media.js +1 -1
  38. package/test-provider-compaction.js +66 -1
  39. package/test-provider-dream.js +3 -1
  40. package/test-provider-events.js +106 -0
  41. package/test-provider-language.js +16 -1
  42. package/test-provider-pack-review.js +3 -1
  43. package/test-provider-scheduler.js +72 -1
  44. package/test-thread-lanes.js +6 -1
  45. package/test-usage-accounting.js +4 -0
  46. package/web.js +104 -51
package/web.js CHANGED
@@ -373,6 +373,8 @@ async function liveChannels() {
373
373
 
374
374
  // ── API routes ─────────────────────────────────────────────────────
375
375
 
376
+ const USER_ACCESS_ROUTES = new Set(["/api/auth/approve", "/api/auth/deny", "/api/auth/remove"]);
377
+
376
378
  async function handleAPI(req, res, body, dependencies = {}) {
377
379
  const url = req.url;
378
380
 
@@ -406,7 +408,9 @@ async function handleAPI(req, res, body, dependencies = {}) {
406
408
  }
407
409
 
408
410
  // ── Authentication (Claude / Codex) ──
409
- if (url.startsWith("/api/auth/")) {
411
+ // /api/auth/approve|deny|remove are user access (the Users tab), handled further down; they
412
+ // must not fall into this provider sign-in block, whose catch-all answers 404.
413
+ if (url.startsWith("/api/auth/") && !USER_ACCESS_ROUTES.has(url)) {
410
414
  const webAuth = require("./core/web-auth");
411
415
  const json = (obj, c = 200) => { res.writeHead(c, { "Content-Type": "application/json" }); return res.end(JSON.stringify(obj)); };
412
416
  const post = req.method === "POST";
@@ -652,7 +656,8 @@ async function handleAPI(req, res, body, dependencies = {}) {
652
656
 
653
657
  // Token usage history (for the Usage dashboard)
654
658
  if (url === "/api/usage") {
655
- const records = loadUsageHistory();
659
+ // Turn records only: compaction records (kind "compaction") carry no turn.
660
+ const records = loadUsageHistory().filter((r) => r.kind !== "compaction");
656
661
  const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "package.json"), "utf-8"));
657
662
  const env = loadEnv();
658
663
  const alertPolicy = usageAlertPolicy(env);
@@ -810,8 +815,14 @@ function botDisplayName() {
810
815
  return "Open Claudia";
811
816
  }
812
817
 
818
+ // BOT_NAME lands inside the page's HTML and inside JS template literals in
819
+ // the page script, so it is escaped for both (entities decode back to text).
820
+ function pageEsc(s) {
821
+ return String(s).replace(/[&<>"'`$\\]/g, (c) => "&#" + c.charCodeAt(0) + ";");
822
+ }
823
+
813
824
  function getHTML() {
814
- const botName = botDisplayName();
825
+ const botName = pageEsc(botDisplayName());
815
826
  return `<!DOCTYPE html>
816
827
  <html lang="en">
817
828
  <head>
@@ -922,7 +933,7 @@ async function doLogin() {
922
933
  const pw = $("#pw").value;
923
934
  const r = await api("/api/login", { method: "POST", body: { password: pw } });
924
935
  if (r?.ok) init();
925
- else $("#login-msg").innerHTML = '<div class="msg err">' + (r?.error || "Wrong password") + '</div>';
936
+ else $("#login-msg").innerHTML = '<div class="msg err">' + esc(r?.error || "Wrong password") + '</div>';
926
937
  }
927
938
 
928
939
  function render() {
@@ -946,7 +957,7 @@ function render() {
946
957
  $("#app").innerHTML = \`
947
958
  <div class="container">
948
959
  <h1>${botName}</h1>
949
- <p class="subtitle">v\${status.version}</p>
960
+ <p class="subtitle">v\${esc(status.version)}</p>
950
961
  <div class="tabs">
951
962
  \${tabs.map(t => \`<div class="tab \${currentTab===t.id?'active':''}" onclick="switchTab('\${t.id}')">\${t.label}</div>\`).join("")}
952
963
  </div>
@@ -962,27 +973,34 @@ function switchTab(tab) {
962
973
  render();
963
974
  }
964
975
 
965
- // ── Chat tab ──
966
- function chatEsc(s) {
967
- return String(s == null ? "" : s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
976
+ // Every server-provided string that goes into innerHTML passes through esc.
977
+ function esc(s) {
978
+ return String(s == null ? "" : s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
968
979
  }
980
+ // A value passed as an argument in an inline onclick="...": a JS string
981
+ // literal, then escaped for the attribute.
982
+ function jsArg(v) { return esc(JSON.stringify(String(v == null ? "" : v))); }
983
+ // Only http(s) links are clickable.
984
+ function safeUrl(u) { return /^https?:\\/\\//i.test(String(u || "")) ? esc(u) : "#"; }
985
+
986
+ // ── Chat tab ──
969
987
 
970
988
  function chatFrameHTML(f) {
971
989
  if (f.kind === "message") {
972
990
  const cls = f.role === "user" ? "user" : "bot";
973
- let inner = chatEsc(f.text);
991
+ let inner = esc(f.text);
974
992
  if (f.buttons && f.buttons.length) {
975
993
  inner += '<div class="chat-btns">' + f.buttons.map(function (b) {
976
- return '<button onclick="chatAction(\\'' + chatEsc(b.id) + '\\',\\'' + chatEsc(b.payload || b.id) + '\\',\\'' + chatEsc(f.messageId) + '\\')">' + chatEsc(b.label) + '</button>';
994
+ return '<button onclick="chatAction(' + jsArg(b.id) + ',' + jsArg(b.payload || b.id) + ',' + jsArg(f.messageId) + ')">' + esc(b.label) + '</button>';
977
995
  }).join("") + '</div>';
978
996
  }
979
- return '<div class="chat-bubble ' + cls + '" data-mid="' + chatEsc(f.messageId) + '">' + inner + '</div>';
997
+ return '<div class="chat-bubble ' + cls + '" data-mid="' + esc(f.messageId) + '">' + inner + '</div>';
980
998
  }
981
999
  if (f.kind === "file") {
982
- return '<div class="chat-bubble bot" data-mid="' + chatEsc(f.messageId) + '">📎 <a href="' + chatEsc(f.url) + '" target="_blank" rel="noopener">' + chatEsc(f.fileName || "file") + '</a>' + (f.caption ? '<br>' + chatEsc(f.caption) : '') + '</div>';
1000
+ return '<div class="chat-bubble bot" data-mid="' + esc(f.messageId) + '">📎 <a href="' + safeUrl(f.url) + '" target="_blank" rel="noopener">' + esc(f.fileName || "file") + '</a>' + (f.caption ? '<br>' + esc(f.caption) : '') + '</div>';
983
1001
  }
984
1002
  if (f.kind === "voice") {
985
- return '<div class="chat-bubble bot" data-mid="' + chatEsc(f.messageId) + '"><audio controls src="' + chatEsc(f.url) + '"></audio></div>';
1003
+ return '<div class="chat-bubble bot" data-mid="' + esc(f.messageId) + '"><audio controls src="' + esc(f.url) + '"></audio></div>';
986
1004
  }
987
1005
  return "";
988
1006
  }
@@ -1071,13 +1089,13 @@ async function loadTab() {
1071
1089
  <div class="card">
1072
1090
  <div class="status-bar">
1073
1091
  <span><span class="status-dot green"></span> Bot configured</span>
1074
- <span>v\${status.version}</span>
1092
+ <span>v\${esc(status.version)}</span>
1075
1093
  </div>
1076
- <div class="list-item"><span>Workspace</span><span>\${status.workspace}</span></div>
1077
- <div class="list-item"><span>Bot Token</span><span>\${status.botToken}</span></div>
1078
- <div class="list-item"><span>Chat ID</span><span>\${status.chatId}</span></div>
1079
- <div class="list-item"><span>Default provider</span><span>\${status.defaultProvider?.label || "None (model turns unavailable)"}</span></div>
1080
- \${(status.providers || []).map(p => '<div class="list-item"><span>' + p.label + '</span><span>' + p.availability.state + ' · ' + p.compatibility.state + ' · auth ' + p.auth.state + '</span></div>').join("")}
1094
+ <div class="list-item"><span>Workspace</span><span>\${esc(status.workspace)}</span></div>
1095
+ <div class="list-item"><span>Bot Token</span><span>\${esc(status.botToken)}</span></div>
1096
+ <div class="list-item"><span>Chat ID</span><span>\${esc(status.chatId)}</span></div>
1097
+ <div class="list-item"><span>Default provider</span><span>\${esc(status.defaultProvider?.label || "None (model turns unavailable)")}</span></div>
1098
+ \${(status.providers || []).map(providerRowHTML).join("")}
1081
1099
  </div>\`;
1082
1100
  }
1083
1101
  else if (currentTab === "usage") {
@@ -1094,7 +1112,7 @@ async function loadTab() {
1094
1112
  el.innerHTML =
1095
1113
  '<div class="card">' +
1096
1114
  '<h2>Token Rate</h2>' +
1097
- '<p style="color:#888;font-size:13px;">Current: v' + u.currentVersion + ' · ' + u.totalRecords + ' turns logged</p>' +
1115
+ '<p style="color:#888;font-size:13px;">Current: v' + esc(u.currentVersion) + ' · ' + esc(u.totalRecords) + ' turns logged</p>' +
1098
1116
  '<div class="metric-grid">' +
1099
1117
  '<div class="metric"><div class="label">Latest context</div><div class="value">' + fmtK(latestCtx) + '</div></div>' +
1100
1118
  '<div class="metric"><div class="label">Recent baseline</div><div class="value">' + fmtK(baselineCtx) + '</div></div>' +
@@ -1123,8 +1141,8 @@ async function loadTab() {
1123
1141
  <h2>Authorized Users</h2>
1124
1142
  \${auth.authorized.map(a => \`
1125
1143
  <div class="list-item">
1126
- <span>\${a.name || a.chatId} \${a.username ? "@"+a.username : ""} \${a.isOwner ? "(owner)" : ""}</span>
1127
- \${!a.isOwner ? \`<button class="danger" onclick="removeAuth('\${a.chatId}')">Remove</button>\` : ""}
1144
+ <span>\${esc(a.name || a.chatId)} \${a.username ? "@"+esc(a.username) : ""} \${a.isOwner ? "(owner)" : ""}</span>
1145
+ \${!a.isOwner ? \`<button class="danger" onclick="removeAuth(\${jsArg(a.chatId)})">Remove</button>\` : ""}
1128
1146
  </div>\`).join("") || "<p>None</p>"}
1129
1147
  </div>
1130
1148
  \${auth.pending.length > 0 ? \`
@@ -1132,10 +1150,10 @@ async function loadTab() {
1132
1150
  <h2>Pending Requests</h2>
1133
1151
  \${auth.pending.map(p => \`
1134
1152
  <div class="list-item">
1135
- <span>\${p.name} \${p.username ? "@"+p.username : ""}</span>
1153
+ <span>\${esc(p.name)} \${p.username ? "@"+esc(p.username) : ""}</span>
1136
1154
  <span>
1137
- <button onclick="approveAuth('\${p.chatId}')">Approve</button>
1138
- <button class="danger" onclick="denyAuth('\${p.chatId}')">Deny</button>
1155
+ <button onclick="approveAuth(\${jsArg(p.chatId)})">Approve</button>
1156
+ <button class="danger" onclick="denyAuth(\${jsArg(p.chatId)})">Deny</button>
1139
1157
  </span>
1140
1158
  </div>\`).join("")}
1141
1159
  </div>\` : ""}\`;
@@ -1175,7 +1193,7 @@ async function loadTab() {
1175
1193
  <div class="card">
1176
1194
  <div class="form-group">
1177
1195
  <label>Assistant Identity & Personality</label>
1178
- <textarea id="soul-content" rows="15">\${soul.content}</textarea>
1196
+ <textarea id="soul-content" rows="15">\${esc(soul.content)}</textarea>
1179
1197
  </div>
1180
1198
  <button onclick="saveSoul()">Save</button>
1181
1199
  <span id="soul-msg" style="margin-left:12px;font-size:13px;color:#888;"></span>
@@ -1189,7 +1207,7 @@ async function loadTab() {
1189
1207
  <h2>Scheduled Tasks</h2>
1190
1208
  \${crons.map((c, i) => \`
1191
1209
  <div class="list-item">
1192
- <span>\${c.label || c.prompt.slice(0,40)} — \${c.schedule} — \${c.project}</span>
1210
+ <span>\${esc(c.label || String(c.prompt || "").slice(0,40))} — \${esc(c.schedule)} — \${esc(c.project)}</span>
1193
1211
  </div>\`).join("") || "<p>No cron jobs. Use /cron in Telegram to add them.</p>"}
1194
1212
  </div>\`;
1195
1213
  }
@@ -1201,7 +1219,7 @@ async function loadTab() {
1201
1219
  <h2>Conversation History</h2>
1202
1220
  \${sessions.slice().reverse().slice(0,25).map(s => \`
1203
1221
  <div class="list-item">
1204
- <span>\${s.title || s.id} · \${s.provider || "legacy"} · \${s.project}</span>
1222
+ <span>\${esc(s.title || s.id)} · \${esc(s.provider || "legacy")} · \${esc(s.project)}</span>
1205
1223
  <span style="color:#666;font-size:12px;">\${s.lastUsed ? new Date(s.lastUsed).toLocaleDateString() : "unknown"}</span>
1206
1224
  </div>\`).join("") || "<p>No sessions yet.</p>"}
1207
1225
  </div>\`;
@@ -1220,7 +1238,7 @@ async function loadTab() {
1220
1238
  el.innerHTML = '<div class="card">' +
1221
1239
  '<div style="display:flex;justify-content:space-between;align-items:center;gap:8px;margin-bottom:8px;">' +
1222
1240
  '<h2 style="margin:0">Browser</h2>' +
1223
- (list.length > 1 ? '<select onchange="window.browserPick=this.value;loadTab()">' + list.map(x => '<option value="' + chatEsc(x.name) + '"' + (x.name === pick ? ' selected' : '') + '>' + chatEsc(x.name) + '</option>').join('') + '</select>' : '') +
1241
+ (list.length > 1 ? '<select onchange="window.browserPick=this.value;loadTab()">' + list.map(x => '<option value="' + esc(x.name) + '"' + (x.name === pick ? ' selected' : '') + '>' + esc(x.name) + '</option>').join('') + '</select>' : '') +
1224
1242
  '</div>' +
1225
1243
  '<div id="bv-url" style="color:#888;font-size:12px;margin-bottom:8px;word-break:break-all"></div>' +
1226
1244
  '<img id="bv-img" alt="" style="width:100%;border-radius:8px;background:#000;min-height:120px">' +
@@ -1247,7 +1265,7 @@ async function loadTab() {
1247
1265
  <h2 style="margin:0">Logs</h2>
1248
1266
  <button class="secondary" onclick="loadTab()">Refresh</button>
1249
1267
  </div>
1250
- <pre>\${logs.content}</pre>
1268
+ <pre>\${esc(logs.content)}</pre>
1251
1269
  </div>\`;
1252
1270
  }
1253
1271
  else if (currentTab === "settings") {
@@ -1307,7 +1325,7 @@ async function loadTab() {
1307
1325
  <div class="form-group">
1308
1326
  <label>\${f.label}</label>
1309
1327
  <div style="color:#777;font-size:11px;margin:2px 0 6px;">\${f.desc}</div>
1310
- <input id="cfg-\${f.k}" value="\${config[f.k] || ""}" placeholder="\${f.ph || ""}">
1328
+ <input id="cfg-\${f.k}" value="\${esc(config[f.k] || "")}" placeholder="\${f.ph || ""}">
1311
1329
  </div>\`).join("")}
1312
1330
  </div>\`).join("");
1313
1331
  }
@@ -1323,12 +1341,12 @@ function usagePolicyHTML(policy, trend) {
1323
1341
  const parts = [];
1324
1342
  if (policy.absoluteContextTokens > 0) parts.push('absolute ceiling ' + fmtK(policy.absoluteContextTokens));
1325
1343
  if (policy.rateMultiplier > 0) {
1326
- parts.push('rate ceiling ' + policy.rateMultiplier + 'x after ' + policy.minBaselineTurns + ' baseline turns');
1344
+ parts.push('rate ceiling ' + esc(policy.rateMultiplier) + 'x after ' + esc(policy.minBaselineTurns) + ' baseline turns');
1327
1345
  }
1328
1346
  const baseline = trend && trend.baselineTurns ? trend.baselineTurns : 0;
1329
1347
  return '<div class="policy">Alert policy: ' + parts.join(' · ') +
1330
1348
  ' · cooldown ' + Math.round((policy.cooldownMs || 0) / 60000) + ' min' +
1331
- ' · baseline sample ' + baseline + '/' + policy.baselineTurns + ' turns</div>';
1349
+ ' · baseline sample ' + esc(baseline) + '/' + esc(policy.baselineTurns) + ' turns</div>';
1332
1350
  }
1333
1351
 
1334
1352
  function usageTableHTML(rows, keyName) {
@@ -1337,9 +1355,9 @@ function usageTableHTML(rows, keyName) {
1337
1355
  '</th><th>turns</th><th>avg ctx/turn</th><th>avg in</th><th>avg out</th><th>cost</th></tr></thead><tbody>';
1338
1356
  const body = rows.map(function (r) {
1339
1357
  const name = r[keyName] || "—";
1340
- return '<tr><td>' + name + '</td><td>' + r.turns + '</td><td>' + fmtK(r.avgContextTokens) +
1358
+ return '<tr><td>' + esc(name) + '</td><td>' + esc(r.turns) + '</td><td>' + fmtK(r.avgContextTokens) +
1341
1359
  '</td><td>' + fmtK(r.avgInputTokens) + '</td><td>' + fmtK(r.avgOutputTokens) +
1342
- '</td><td>$' + (r.totalCostUsd || 0).toFixed(3) + '</td></tr>';
1360
+ '</td><td>$' + Number(r.totalCostUsd || 0).toFixed(3) + '</td></tr>';
1343
1361
  }).join("");
1344
1362
  return head + body + "</tbody></table>";
1345
1363
  }
@@ -1410,7 +1428,7 @@ async function pollClaudeAuth() {
1410
1428
  }
1411
1429
  if (st.url) {
1412
1430
  box.innerHTML = \`<p style="font-size:13px;">Open this URL, sign in, then paste the code Claude gives you:</p>
1413
- <p style="word-break:break-all;"><a href="\${st.url}" target="_blank">\${st.url}</a></p>
1431
+ <p style="word-break:break-all;"><a href="\${safeUrl(st.url)}" target="_blank" rel="noopener">\${esc(st.url)}</a></p>
1414
1432
  <input id="claude-code" placeholder="Paste code here">
1415
1433
  <button onclick="submitClaudeCodeWeb()" style="margin-top:8px;">Submit code</button>\`;
1416
1434
  } else {
@@ -1433,7 +1451,7 @@ async function saveClaudeTokenWeb() {
1433
1451
  async function startCodexAuth() {
1434
1452
  $("#codex-flow").innerHTML = "<p class='subtitle'>Starting…</p>";
1435
1453
  const r = await api("/api/auth/codex/start", { method: "POST" });
1436
- if (r && r.error) { $("#codex-flow").innerHTML = "<div class='msg err'>" + r.error + "</div>"; return; }
1454
+ if (r && r.error) { $("#codex-flow").innerHTML = "<div class='msg err'>" + esc(r.error) + "</div>"; return; }
1437
1455
  pollCodexAuth();
1438
1456
  }
1439
1457
  async function pollCodexAuth() {
@@ -1449,8 +1467,8 @@ async function pollCodexAuth() {
1449
1467
  }
1450
1468
  if (st.url) {
1451
1469
  box.innerHTML = \`<p style="font-size:13px;">Open this URL and enter the code:</p>
1452
- <p style="word-break:break-all;"><a href="\${st.url}" target="_blank">\${st.url}</a></p>
1453
- \${st.code ? "<p>Device code: <b>" + st.code + "</b></p>" : ""}
1470
+ <p style="word-break:break-all;"><a href="\${safeUrl(st.url)}" target="_blank" rel="noopener">\${esc(st.url)}</a></p>
1471
+ \${st.code ? "<p>Device code: <b>" + esc(st.code) + "</b></p>" : ""}
1454
1472
  <p class='subtitle'>Waiting for you to finish in the browser…</p>\`;
1455
1473
  } else {
1456
1474
  box.innerHTML = "<p class='subtitle'>Starting…</p>";
@@ -1486,10 +1504,14 @@ async function saveConfig() {
1486
1504
  setTimeout(() => $("#config-msg").textContent = "", 3000);
1487
1505
  }
1488
1506
 
1507
+ function providerRowHTML(p) {
1508
+ return '<div class="list-item"><span>' + esc(p.label) + '</span><span>' + esc(p.availability.state) + ' · ' + esc(p.compatibility.state) + ' · auth ' + esc(p.auth.state) + '</span></div>';
1509
+ }
1510
+
1489
1511
  function renderSetup() {
1490
1512
  const providerRows = (status.providers || []).map(p =>
1491
- '<div class="list-item"><span>' + p.label + '</span><span>' + p.availability.state + ' · ' + p.compatibility.state + ' · auth ' + p.auth.state + '</span></div>' +
1492
- (p.recovery ? '<div style="color:#888;font-size:11px;margin:-8px 0 10px;">' + p.recovery.split("\\n")[0] + '</div>' : "")
1513
+ providerRowHTML(p) +
1514
+ (p.recovery ? '<div style="color:#888;font-size:11px;margin:-8px 0 10px;">' + esc(String(p.recovery).split("\\n")[0]) + '</div>' : "")
1493
1515
  ).join("");
1494
1516
  $("#app").innerHTML = \`
1495
1517
  <div class="container">
@@ -1529,7 +1551,7 @@ async function doSetup() {
1529
1551
  currentTab = "dashboard";
1530
1552
  await init();
1531
1553
  } else {
1532
- $("#setup-msg").innerHTML = \`<div class="msg err">\${r?.error || "Setup failed"}</div>\`;
1554
+ $("#setup-msg").innerHTML = \`<div class="msg err">\${esc(r?.error || "Setup failed")}</div>\`;
1533
1555
  }
1534
1556
  }
1535
1557
 
@@ -1541,23 +1563,55 @@ init();
1541
1563
 
1542
1564
  // ── Server ─────────────────────────────────────────────────────────
1543
1565
 
1566
+ // A failing handler answers 500 and is logged here. It must never become an
1567
+ // unhandled rejection: bot.js reports those to the owner, and some routes
1568
+ // (/otp/) need no sign-in, so anyone could make the bot message its owner.
1569
+ function answerFailure(res, err) {
1570
+ console.error("web: request failed:", err?.message || err);
1571
+ if (res.headersSent) { res.destroy(); return; }
1572
+ res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
1573
+ res.end("Server error\n");
1574
+ }
1575
+
1544
1576
  let webServer = null;
1545
1577
 
1546
1578
  function startWebServer(options = {}) {
1547
1579
  if (webServer) return webServer;
1548
1580
  ensureConfigDir(CONFIG_DIR);
1549
1581
 
1550
- const server = http.createServer(async (req, res) => {
1582
+ const server = http.createServer((req, res) => {
1583
+ handleRequest(req, res).catch((e) => answerFailure(res, e));
1584
+ });
1585
+ async function handleRequest(req, res) {
1551
1586
  // CORS for local dev
1552
1587
  res.setHeader("Access-Control-Allow-Origin", "*");
1553
1588
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
1554
1589
  res.setHeader("Access-Control-Allow-Headers", "Content-Type");
1555
1590
  if (req.method === "OPTIONS") { res.writeHead(204); return res.end(); }
1556
1591
 
1557
- // One-time magic-link login: /otp/<token> from the bot's /dashboard
1558
- // command. Burns the token, sets the session cookie, redirects to /.
1559
- if (req.method === "GET" && req.url.startsWith("/otp/")) {
1560
- const token = decodeURIComponent(req.url.slice(5).split("?")[0].split("/")[0]);
1592
+ // One-time magic-link login: /otp/<token> from the bot's /dashboard command. Opening the
1593
+ // link (GET) only shows a page that posts it back; the POST burns the token, sets the
1594
+ // session cookie and redirects to /. Link previews and scanners only fetch (Kazee Chat
1595
+ // fetches every link in a message, the bot's own replies included: HEAD, then GET), so they
1596
+ // can no longer use the link up before the person opens it. A malformed link answers 400.
1597
+ if (req.url.startsWith("/otp/") && ["GET", "HEAD", "POST"].includes(req.method)) {
1598
+ let token;
1599
+ try { token = decodeURIComponent(req.url.slice(5).split("?")[0].split("/")[0]); }
1600
+ catch (e) {
1601
+ res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
1602
+ return res.end("Bad link. Send /dashboard in the bot for a fresh one.\n");
1603
+ }
1604
+ const page = (status, inner, extra = "") => {
1605
+ res.writeHead(status, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store", "Referrer-Policy": "same-origin", "X-Robots-Tag": "noindex" });
1606
+ if (req.method === "HEAD") return res.end();
1607
+ return res.end(`<!DOCTYPE html><html><head><meta name="viewport" content="width=device-width, initial-scale=1.0"><style>body{font-family:-apple-system,sans-serif;background:#0f0f0f;color:#e0e0e0;display:flex;align-items:center;justify-content:center;min-height:100vh;text-align:center;padding:20px}button{font-size:16px;padding:10px 18px;border-radius:8px;border:0;background:#6366f1;color:#fff}</style></head><body><div>${inner}</div>${extra}</body></html>`);
1608
+ };
1609
+ const expired = () => page(401, '<h2>Link expired or already used</h2><p style="color:#888">Send /dashboard in the bot for a fresh link.</p>');
1610
+ if (!/^[0-9a-f]{64}$/.test(token)) return expired();
1611
+ if (req.method !== "POST") {
1612
+ return page(200, `<form method="post" action="/otp/${token}"><h2>Opening your dashboard…</h2><button type="submit">Open dashboard</button></form>`, "<script>document.forms[0].submit()</script>");
1613
+ }
1614
+ req.resume();
1561
1615
  const { consume } = require("./core/web-otp");
1562
1616
  if (consume(token)) {
1563
1617
  res.writeHead(302, {
@@ -1566,21 +1620,20 @@ function startWebServer(options = {}) {
1566
1620
  });
1567
1621
  return res.end();
1568
1622
  }
1569
- res.writeHead(401, { "Content-Type": "text/html" });
1570
- return res.end('<!DOCTYPE html><html><head><meta name="viewport" content="width=device-width, initial-scale=1.0"><style>body{font-family:-apple-system,sans-serif;background:#0f0f0f;color:#e0e0e0;display:flex;align-items:center;justify-content:center;min-height:100vh;text-align:center;padding:20px}</style></head><body><div><h2>Link expired or already used</h2><p style="color:#888">Send /dashboard in the bot for a fresh link.</p></div></body></html>');
1623
+ return expired();
1571
1624
  }
1572
1625
 
1573
1626
  if (req.url.startsWith("/api/")) {
1574
1627
  let body = "";
1575
1628
  req.on("data", (d) => { body += d; });
1576
- req.on("end", () => handleAPI(req, res, body, options));
1629
+ req.on("end", () => handleAPI(req, res, body, options).catch((e) => answerFailure(res, e)));
1577
1630
  return;
1578
1631
  }
1579
1632
 
1580
1633
  // Serve HTML
1581
1634
  res.writeHead(200, { "Content-Type": "text/html" });
1582
1635
  res.end(getHTML());
1583
- });
1636
+ }
1584
1637
 
1585
1638
  server.listen(PORT, () => {
1586
1639
  console.log(`Web UI running on http://localhost:${PORT}`);