@lifeaitools/clauth 1.4.3 → 1.4.5

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 (2) hide show
  1. package/cli/commands/serve.js +183 -17
  2. package/package.json +1 -1
@@ -502,10 +502,18 @@ function dashboardHtml(port, whitelist, isStaged = false) {
502
502
  .card:hover{border-color:#3b82f6}
503
503
  .card-name{font-size:1rem;font-weight:600;color:#f8fafc;margin-bottom:3px}
504
504
  .card-type{font-size:.78rem;color:#64748b;text-transform:uppercase;letter-spacing:.5px}
505
- .card-getkey{font-size:.75rem;color:#3b82f6;text-decoration:none;opacity:.7;transition:opacity .15s}
506
- .card-getkey:hover{opacity:1;text-decoration:underline}
505
+ .card-getkey{font-size:.75rem;color:#60a5fa;text-decoration:none;transition:color .15s}
506
+ .card-getkey:hover{color:#93c5fd;text-decoration:underline}
507
507
  .card-value{font-family:'Courier New',monospace;font-size:.82rem;color:#22c55e;background:#0f172a;border-radius:4px;padding:8px 10px;margin-top:10px;word-break:break-all;max-height:80px;overflow:auto;display:none}
508
508
  .card-actions{margin-top:10px;display:flex;gap:7px;flex-wrap:wrap}
509
+ .btn-rename{background:#1e293b;color:#94a3b8;border:1px solid #334155;padding:3px 8px;font-size:.75rem;border-radius:4px;cursor:pointer;transition:color .15s,border-color .15s}
510
+ .btn-rename:hover{color:#e2e8f0;border-color:#60a5fa}
511
+ .btn-delete{background:#1e293b;color:#f87171;border:1px solid #4b2020;padding:3px 8px;font-size:.75rem;border-radius:4px;cursor:pointer;transition:background .15s,border-color .15s}
512
+ .btn-delete:hover{background:#2d1f1f;border-color:#ef4444}
513
+ .rename-panel{display:none;margin-top:8px;background:#0f172a;border-radius:6px;padding:8px 10px;border:1px solid #1e3a5f}
514
+ .rename-input{width:calc(100% - 100px);background:#0a0f1a;border:1px solid #1e3a5f;border-radius:4px;color:#e2e8f0;font-size:.85rem;padding:5px 8px;outline:none}
515
+ .rename-input:focus{border-color:#3b82f6}
516
+ .rename-msg{font-size:.75rem;margin-left:6px}
509
517
  .set-panel{display:none;margin-top:10px;background:#0f172a;border-radius:6px;padding:10px;border:1px solid #1e3a5f}
510
518
  .set-panel label{font-size:.75rem;color:#64748b;display:block;margin-bottom:6px}
511
519
  .set-input{width:100%;background:#0a0f1a;border:1px solid #1e3a5f;border-radius:4px;color:#e2e8f0;font-family:'Courier New',monospace;font-size:.85rem;padding:7px 10px;outline:none;resize:vertical;min-height:58px;transition:border-color .2s}
@@ -887,10 +895,12 @@ const KEY_URLS = {
887
895
  // Extra links shown below the primary KEY_URLS link
888
896
  const EXTRA_LINKS = {
889
897
  "gmail": [
898
+ { label: "↗ Open Gmail", url: "https://mail.google.com" },
890
899
  { label: "↗ OAuth Playground (get refresh token)", url: "https://developers.google.com/oauthplayground/" },
891
900
  { label: "↗ Enable Gmail API", url: "https://console.cloud.google.com/apis/library/gmail.googleapis.com" },
892
901
  ],
893
902
  "gcal": [
903
+ { label: "↗ Open Google Calendar", url: "https://calendar.google.com" },
894
904
  { label: "↗ OAuth Playground (get refresh token)", url: "https://developers.google.com/oauthplayground/" },
895
905
  { label: "↗ Enable Calendar API", url: "https://console.cloud.google.com/apis/library/calendar-json.googleapis.com" },
896
906
  ],
@@ -959,20 +969,35 @@ async function boot() {
959
969
  try {
960
970
  const ping = await fetch(BASE + "/ping").then(r => r.json());
961
971
  if (ping.locked) {
962
- showLockScreen();
972
+ showLockScreen(ping.hard_locked);
963
973
  } else {
964
974
  showMain(ping);
965
975
  loadServices();
966
976
  }
967
977
  } catch {
968
- showLockScreen();
978
+ showLockScreen(false);
969
979
  }
970
980
  }
971
981
 
972
- function showLockScreen() {
982
+ function showLockScreen(hardLocked) {
973
983
  document.getElementById("lock-screen").style.display = "flex";
974
984
  document.getElementById("main-view").style.display = "none";
975
- setTimeout(() => document.getElementById("lock-input").focus(), 50);
985
+ const input = document.getElementById("lock-input");
986
+ const btn = document.getElementById("unlock-btn");
987
+ const sub = document.getElementById("lock-sub");
988
+ const err = document.getElementById("lock-err");
989
+ if (hardLocked) {
990
+ input.disabled = true;
991
+ btn.disabled = true;
992
+ sub.textContent = "Too many failed attempts";
993
+ err.textContent = "✗ Vault locked — restart daemon to try again";
994
+ } else {
995
+ input.disabled = false;
996
+ btn.disabled = false;
997
+ sub.textContent = "Paste your password to unlock";
998
+ err.textContent = "";
999
+ setTimeout(() => input.focus(), 50);
1000
+ }
976
1001
  }
977
1002
 
978
1003
  // ── Upgrade detection ────────────────────────────────────────────
@@ -1011,6 +1036,11 @@ function showMain(ping) {
1011
1036
  checkUpgrade(ping);
1012
1037
  document.getElementById("lock-screen").style.display = "none";
1013
1038
  document.getElementById("main-view").style.display = "block";
1039
+ // Reset lock screen state for next lock
1040
+ document.getElementById("lock-input").disabled = false;
1041
+ document.getElementById("unlock-btn").disabled = false;
1042
+ document.getElementById("lock-sub").textContent = "Paste your password to unlock";
1043
+ document.getElementById("lock-err").textContent = "";
1014
1044
  if (ping) {
1015
1045
  document.getElementById("s-pid").textContent = ping.pid || "—";
1016
1046
  document.getElementById("s-fails").textContent =
@@ -1025,8 +1055,10 @@ async function unlock() {
1025
1055
  const input = document.getElementById("lock-input");
1026
1056
  const btn = document.getElementById("unlock-btn");
1027
1057
  const err = document.getElementById("lock-err");
1058
+ const sub = document.getElementById("lock-sub");
1028
1059
  const pw = input.value;
1029
1060
 
1061
+ if (input.disabled) return;
1030
1062
  if (!pw) { err.textContent = "Password is required."; return; }
1031
1063
 
1032
1064
  btn.disabled = true; btn.textContent = "Verifying…"; err.textContent = "";
@@ -1038,7 +1070,22 @@ async function unlock() {
1038
1070
  body: JSON.stringify({ password: pw })
1039
1071
  }).then(r => r.json());
1040
1072
 
1041
- if (r.error) throw new Error(r.error);
1073
+ if (r.hard_locked) {
1074
+ input.value = "";
1075
+ input.disabled = true;
1076
+ btn.disabled = true;
1077
+ btn.textContent = "Unlock";
1078
+ sub.textContent = "Too many failed attempts";
1079
+ err.textContent = "✗ Vault locked — restart daemon to try again";
1080
+ return;
1081
+ }
1082
+
1083
+ if (r.error) {
1084
+ const rem = r.failures_remaining !== undefined
1085
+ ? \` (\${r.failures_remaining} attempt\${r.failures_remaining !== 1 ? "s" : ""} remaining)\`
1086
+ : "";
1087
+ throw new Error(r.error + rem);
1088
+ }
1042
1089
 
1043
1090
  input.value = "";
1044
1091
  const ping = await fetch(BASE + "/ping").then(r => r.json());
@@ -1050,14 +1097,14 @@ async function unlock() {
1050
1097
  err.textContent = "✗ " + (e.message || "Invalid password");
1051
1098
  setTimeout(() => input.className = "lock-input", 600);
1052
1099
  } finally {
1053
- btn.disabled = false; btn.textContent = "Unlock";
1100
+ if (!input.disabled) { btn.disabled = false; btn.textContent = "Unlock"; }
1054
1101
  }
1055
1102
  }
1056
1103
 
1057
1104
  // ── Lock ────────────────────────────────────
1058
1105
  async function lockVault() {
1059
- await fetch(BASE + "/lock", { method: "POST" }).catch(() => {});
1060
- showLockScreen();
1106
+ const r = await fetch(BASE + "/lock", { method: "POST" }).then(r => r.json()).catch(() => ({}));
1107
+ showLockScreen(r.hard_locked || false);
1061
1108
  }
1062
1109
 
1063
1110
  // ── Make Live (blue-green: promote staged instance to live port) ──
@@ -1155,6 +1202,14 @@ function renderServiceGrid(services) {
1155
1202
  <button class="btn-project" onclick="toggleProjectEdit('\${s.name}')">\${s.project ? "✎ Project" : "+ Project"}</button>
1156
1203
  <button class="btn \${s.enabled === false ? "btn-enable" : "btn-disable"}" id="togbtn-\${s.name}" onclick="toggleService('\${s.name}')">\${s.enabled === false ? "Enable" : "Disable"}</button>
1157
1204
  <button class="btn-rotate" id="rotbtn-\${s.name}" style="display:none;background:#0e7490;border:1px solid #06b6d4;color:#cffafe;font-size:.75rem;padding:3px 8px;border-radius:4px;cursor:pointer" onclick="rotateKey('\${s.name}')">↻ Rotate</button>
1205
+ <button class="btn-rename" onclick="toggleRename('\${s.name}')" title="Rename service">✎</button>
1206
+ <button class="btn-delete" onclick="deleteService('\${s.name}')" title="Delete service">✕</button>
1207
+ </div>
1208
+ <div class="rename-panel" id="rn-\${s.name}">
1209
+ <input class="rename-input" id="rn-input-\${s.name}" value="\${s.name}" spellcheck="false" autocomplete="off" placeholder="New name…">
1210
+ <button class="btn" onclick="saveRename('\${s.name}')" style="padding:4px 10px;font-size:.8rem">Save</button>
1211
+ <button class="btn" onclick="toggleRename('\${s.name}')" style="padding:4px 10px;font-size:.8rem;background:#1e293b">Cancel</button>
1212
+ <span class="rename-msg" id="rn-msg-\${s.name}"></span>
1158
1213
  </div>
1159
1214
  <div class="project-edit" id="pe-\${s.name}">
1160
1215
  <input type="text" id="pe-input-\${s.name}" value="\${s.project || ""}" placeholder="Project name…" spellcheck="false" autocomplete="off">
@@ -1360,6 +1415,53 @@ async function clearProject(name) {
1360
1415
  await saveProject(name);
1361
1416
  }
1362
1417
 
1418
+ // ── Rename service ───────────────────────────
1419
+ function toggleRename(name) {
1420
+ const panel = document.getElementById("rn-" + name);
1421
+ const open = panel.style.display === "block";
1422
+ panel.style.display = open ? "none" : "block";
1423
+ if (!open) {
1424
+ const inp = document.getElementById("rn-input-" + name);
1425
+ inp.value = name;
1426
+ inp.focus(); inp.select();
1427
+ document.getElementById("rn-msg-" + name).textContent = "";
1428
+ }
1429
+ }
1430
+
1431
+ async function saveRename(oldName) {
1432
+ const inp = document.getElementById("rn-input-" + oldName);
1433
+ const msg = document.getElementById("rn-msg-" + oldName);
1434
+ const newName = inp.value.trim();
1435
+ if (!newName || newName === oldName) { toggleRename(oldName); return; }
1436
+ msg.style.color = "#94a3b8"; msg.textContent = "Saving…";
1437
+ try {
1438
+ const r = await fetch(BASE + "/rename/" + oldName, {
1439
+ method: "POST",
1440
+ headers: { "Content-Type": "application/json" },
1441
+ body: JSON.stringify({ new_name: newName })
1442
+ }).then(r => r.json());
1443
+ if (r.locked) { showLockScreen(false); return; }
1444
+ if (r.error) throw new Error(r.error);
1445
+ msg.style.color = "#4ade80"; msg.textContent = "✓ Renamed";
1446
+ setTimeout(() => loadServices(), 800);
1447
+ } catch (e) {
1448
+ msg.style.color = "#f87171"; msg.textContent = "✗ " + e.message;
1449
+ }
1450
+ }
1451
+
1452
+ // ── Delete service ───────────────────────────
1453
+ async function deleteService(name) {
1454
+ if (!confirm(\`Delete service "\${name}"? This cannot be undone.\`)) return;
1455
+ try {
1456
+ const r = await fetch(BASE + "/delete/" + name, { method: "POST" }).then(r => r.json());
1457
+ if (r.locked) { showLockScreen(false); return; }
1458
+ if (r.error) throw new Error(r.error);
1459
+ loadServices();
1460
+ } catch (e) {
1461
+ alert("Delete failed: " + e.message);
1462
+ }
1463
+ }
1464
+
1363
1465
  // ── Set key ─────────────────────────────────
1364
1466
  function toggleSet(name) {
1365
1467
  const panel = document.getElementById("set-panel-" + name);
@@ -2279,6 +2381,9 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
2279
2381
  }
2280
2382
  const MAX_FAILS = 10;
2281
2383
  let failCount = 0;
2384
+ const MAX_AUTH_FAILS = 10;
2385
+ let authFailCount = 0;
2386
+ let authHardLocked = false;
2282
2387
  let password = initPassword || null; // null = locked; set via POST /auth
2283
2388
  const machineHash = getMachineHash();
2284
2389
 
@@ -2487,17 +2592,18 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
2487
2592
  error: message,
2488
2593
  failures: failCount,
2489
2594
  failures_remaining: remaining,
2490
- ...(failCount >= MAX_FAILS ? { shutdown: true } : {})
2595
+ ...(failCount >= MAX_FAILS ? { locked: true } : {})
2491
2596
  });
2492
2597
 
2493
2598
  res.writeHead(code, { "Content-Type": "application/json", ...CORS });
2494
2599
  res.end(body);
2495
2600
 
2496
2601
  if (failCount >= MAX_FAILS) {
2497
- const msg = `[${new Date().toISOString()}] Failure limit reached — shutting down\n`;
2602
+ const msg = `[${new Date().toISOString()}] Failure limit reached — locking vault\n`;
2498
2603
  try { fs.appendFileSync(LOG_FILE, msg); } catch {}
2499
- removePid();
2500
- setTimeout(() => process.exit(1), 100);
2604
+ password = null;
2605
+ failCount = 0;
2606
+ stopTunnel();
2501
2607
  }
2502
2608
  }
2503
2609
 
@@ -3013,8 +3119,11 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
3013
3119
  status: "ok",
3014
3120
  pid: process.pid,
3015
3121
  locked: !password,
3122
+ hard_locked: authHardLocked,
3016
3123
  failures: failCount,
3017
3124
  failures_remaining: MAX_FAILS - failCount,
3125
+ auth_failures: authFailCount,
3126
+ auth_failures_remaining: MAX_AUTH_FAILS - authFailCount,
3018
3127
  services: whitelist || "all",
3019
3128
  port,
3020
3129
  tunnel_status: tunnelStatus,
@@ -3285,11 +3394,18 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
3285
3394
  return res.end(JSON.stringify({ error: "password is required" }));
3286
3395
  }
3287
3396
 
3397
+ if (authHardLocked) {
3398
+ res.writeHead(401, { "Content-Type": "application/json", ...CORS });
3399
+ return res.end(JSON.stringify({ error: "Too many failed attempts — restart daemon to try again", hard_locked: true }));
3400
+ }
3401
+
3288
3402
  try {
3289
3403
  const { token, timestamp } = deriveToken(pw, machineHash);
3290
3404
  const result = await api.test(pw, machineHash, token, timestamp);
3291
3405
  if (result.error) throw new Error(result.error);
3292
3406
  password = pw; // unlock — store in process memory only
3407
+ authFailCount = 0;
3408
+ authHardLocked = false;
3293
3409
  const logLine = `[${new Date().toISOString()}] Vault unlocked\n`;
3294
3410
  try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
3295
3411
  // Start rotation engine on unlock
@@ -3341,9 +3457,19 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
3341
3457
  }
3342
3458
  return ok(res, { ok: true, locked: false });
3343
3459
  } catch {
3344
- // Wrong password — not a lockout strike, just a UI auth attempt
3460
+ authFailCount++;
3461
+ const authRemaining = MAX_AUTH_FAILS - authFailCount;
3462
+ const failLog = `[${new Date().toISOString()}] [AUTH FAIL ${authFailCount}/${MAX_AUTH_FAILS}] Wrong password\n`;
3463
+ try { fs.appendFileSync(LOG_FILE, failLog); } catch {}
3464
+ if (authFailCount >= MAX_AUTH_FAILS) {
3465
+ authHardLocked = true;
3466
+ const lockLog = `[${new Date().toISOString()}] Auth failure limit reached — vault hard-locked\n`;
3467
+ try { fs.appendFileSync(LOG_FILE, lockLog); } catch {}
3468
+ res.writeHead(401, { "Content-Type": "application/json", ...CORS });
3469
+ return res.end(JSON.stringify({ error: "Too many failed attempts — restart daemon to try again", hard_locked: true }));
3470
+ }
3345
3471
  res.writeHead(401, { "Content-Type": "application/json", ...CORS });
3346
- return res.end(JSON.stringify({ error: "Invalid password" }));
3472
+ return res.end(JSON.stringify({ error: "Invalid password", failures_remaining: authRemaining }));
3347
3473
  }
3348
3474
  }
3349
3475
 
@@ -3353,7 +3479,47 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
3353
3479
  stopTunnel();
3354
3480
  const logLine = `[${new Date().toISOString()}] Vault locked\n`;
3355
3481
  try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
3356
- return ok(res, { ok: true, locked: true });
3482
+ return ok(res, { ok: true, locked: true, hard_locked: authHardLocked });
3483
+ }
3484
+
3485
+ // POST /rename/:service — rename a service
3486
+ const renameMatch = reqPath.match(/^\/rename\/([a-zA-Z0-9_-]+)$/);
3487
+ if (method === "POST" && renameMatch) {
3488
+ if (lockedGuard(res)) return;
3489
+ const service = renameMatch[1].toLowerCase();
3490
+ let body;
3491
+ try { body = await readBody(req); } catch {
3492
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
3493
+ return res.end(JSON.stringify({ error: "Invalid JSON body" }));
3494
+ }
3495
+ const newName = (body.new_name || "").trim().toLowerCase();
3496
+ if (!newName || !/^[a-z0-9_-]+$/.test(newName)) {
3497
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
3498
+ return res.end(JSON.stringify({ error: "Invalid name — use lowercase letters, numbers, hyphens, underscores" }));
3499
+ }
3500
+ try {
3501
+ const { token, timestamp } = deriveToken(password, machineHash);
3502
+ const result = await api.updateService(password, machineHash, token, timestamp, service, { name: newName, label: newName });
3503
+ if (result.error) return strike(res, 502, result.error);
3504
+ return ok(res, { ok: true, old_name: service, new_name: newName });
3505
+ } catch (err) {
3506
+ return strike(res, 502, err.message);
3507
+ }
3508
+ }
3509
+
3510
+ // POST /delete/:service — remove a service entirely
3511
+ const deleteMatch = reqPath.match(/^\/delete\/([a-zA-Z0-9_-]+)$/);
3512
+ if (method === "POST" && deleteMatch) {
3513
+ if (lockedGuard(res)) return;
3514
+ const service = deleteMatch[1].toLowerCase();
3515
+ try {
3516
+ const { token, timestamp } = deriveToken(password, machineHash);
3517
+ const result = await api.removeService(password, machineHash, token, timestamp, service, true);
3518
+ if (result.error) return strike(res, 502, result.error);
3519
+ return ok(res, { ok: true, deleted: service });
3520
+ } catch (err) {
3521
+ return strike(res, 502, err.message);
3522
+ }
3357
3523
  }
3358
3524
 
3359
3525
  // POST /toggle/:service — enable or disable a service
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lifeaitools/clauth",
3
- "version": "1.4.3",
3
+ "version": "1.4.5",
4
4
  "description": "Hardware-bound credential vault for the LIFEAI infrastructure stack",
5
5
  "type": "module",
6
6
  "bin": {