@lifeaitools/clauth 1.9.1 → 1.9.3

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.
@@ -604,6 +604,22 @@ function isProcessAlive(pid) {
604
604
  try { process.kill(pid, 0); return true; } catch { return false; }
605
605
  }
606
606
 
607
+ // STOP-ON-REJECTION classifier. A server `reason` of invalid_token / machine_locked
608
+ // (and friends) is a terminal verdict: the supplied password/machine is wrong or the
609
+ // machine is locked, so every further attempt only burns another of the 5 server-side
610
+ // strikes toward a DB lockout. Transport failures (fetch failed, ECONNREFUSED, …) are
611
+ // NOT verdicts — they carry no `reason` and are safe to retry. Pure + exported so the
612
+ // regression is unit-testable without a live vault.
613
+ export function isTerminalAuthVerdict(reason) {
614
+ if (!reason || typeof reason !== "string") return false;
615
+ return (
616
+ reason.includes("invalid_token") ||
617
+ reason.includes("machine_locked") ||
618
+ reason.includes("machine_disabled") ||
619
+ reason.includes("machine_not_found")
620
+ );
621
+ }
622
+
607
623
  function openBrowser(url) {
608
624
  try {
609
625
  const cmd = os.platform() === "win32" ? `start "" "${url}"`
@@ -1148,44 +1164,93 @@ const OAUTH_IMPORT = {
1148
1164
  }
1149
1165
  };
1150
1166
 
1151
- function renderSetPanel(name) {
1167
+ function serviceDomId(name) {
1168
+ return encodeURIComponent(String(name)).replace(/%/g, "_");
1169
+ }
1170
+
1171
+ function htmlEscape(value) {
1172
+ return String(value ?? "")
1173
+ .replace(/&/g, "&")
1174
+ .replace(/</g, "&lt;")
1175
+ .replace(/>/g, "&gt;")
1176
+ .replace(/"/g, "&quot;")
1177
+ .replace(/'/g, "&#39;");
1178
+ }
1179
+
1180
+ function jsArg(value) {
1181
+ return JSON.stringify(String(value ?? ""))
1182
+ .replace(/</g, "\\u003c")
1183
+ .replace(/>/g, "\\u003e")
1184
+ .replace(/&/g, "\\u0026")
1185
+ .replace(/'/g, "\\u0027");
1186
+ }
1187
+
1188
+ function renderSetPanel(serviceOrName) {
1189
+ const service = typeof serviceOrName === "object" && serviceOrName ? serviceOrName : { name: serviceOrName };
1190
+ const name = service.name;
1191
+ const keyType = String(service.key_type || "").toLowerCase();
1192
+ const id = serviceDomId(name);
1193
+ const arg = jsArg(name);
1194
+ const displayName = htmlEscape(name);
1152
1195
  const imp = OAUTH_IMPORT[name];
1153
1196
  if (imp) {
1154
1197
  const extraHtml = imp.extra.map(f => \`
1155
1198
  <div class="oauth-field">
1156
1199
  <label class="oauth-label">\${f.label}</label>
1157
1200
  \${f.hint ? \`<div class="oauth-hint">\${f.hint}</div>\` : ""}
1158
- <input type="text" class="oauth-input" id="ofield-\${name}-\${f.key}" placeholder="Paste \${f.label}…" spellcheck="false" autocomplete="off">
1201
+ <input type="text" class="oauth-input" id="ofield-\${id}-\${f.key}" placeholder="Paste \${htmlEscape(f.label)}…" spellcheck="false" autocomplete="off">
1159
1202
  </div>
1160
1203
  \`).join("");
1161
1204
  return \`
1162
- <div class="set-panel" id="set-panel-\${name}">
1163
- <label>Set <strong>\${name}</strong> credentials — paste directly from Google, never in chat</label>
1205
+ <div class="set-panel" id="set-panel-\${id}">
1206
+ <label>Set <strong>\${displayName}</strong> credentials — paste directly from Google, never in chat</label>
1164
1207
  <div class="oauth-fields">
1165
1208
  <div class="oauth-field">
1166
1209
  <label class="oauth-label">OAuth JSON from Google Cloud Console</label>
1167
1210
  <div class="oauth-hint">Download from APIs & Services → Credentials → your OAuth client → ↓ Download JSON</div>
1168
- <textarea class="set-input" id="ofield-\${name}-json" placeholder='{"installed":{"client_id":"…","client_secret":"…",...}}' spellcheck="false" rows="3"></textarea>
1211
+ <textarea class="set-input" id="ofield-\${id}-json" placeholder='{"installed":{"client_id":"…","client_secret":"…",...}}' spellcheck="false" rows="3"></textarea>
1169
1212
  </div>
1170
1213
  \${extraHtml}
1171
1214
  </div>
1172
1215
  <div class="set-foot">
1173
- <button class="btn btn-save" onclick="saveKey('\${name}')">Save</button>
1174
- <button class="btn btn-cancel" onclick="toggleSet('\${name}')">Cancel</button>
1175
- <span class="set-msg" id="set-msg-\${name}"></span>
1216
+ <button class="btn btn-save" type="button" onclick='saveKey(\${arg})'>Save</button>
1217
+ <button class="btn btn-cancel" type="button" onclick='toggleSet(\${arg})'>Cancel</button>
1218
+ <span class="set-msg" id="set-msg-\${id}"></span>
1219
+ </div>
1220
+ </div>
1221
+ \`;
1222
+ }
1223
+ if (keyType === "keypair") {
1224
+ return \`
1225
+ <div class="set-panel" id="set-panel-\${id}">
1226
+ <label>New user/pass pair for <strong>\${displayName}</strong> — paste here, never in chat</label>
1227
+ <div class="oauth-fields">
1228
+ <div class="oauth-field">
1229
+ <label class="oauth-label">User / Key</label>
1230
+ <input type="text" class="oauth-input" id="kp-key-\${id}" placeholder="Username, key id, access key id…" spellcheck="false" autocomplete="off">
1231
+ </div>
1232
+ <div class="oauth-field">
1233
+ <label class="oauth-label">Password / Secret</label>
1234
+ <textarea class="set-input" id="kp-value-\${id}" placeholder="Password, API key, private key, secret…" spellcheck="false" rows="3"></textarea>
1235
+ </div>
1236
+ </div>
1237
+ <div class="set-foot">
1238
+ <button class="btn btn-save" type="button" onclick='saveKey(\${arg})'>Save</button>
1239
+ <button class="btn btn-cancel" type="button" onclick='toggleSet(\${arg})'>Cancel</button>
1240
+ <span class="set-msg" id="set-msg-\${id}"></span>
1176
1241
  </div>
1177
1242
  </div>
1178
1243
  \`;
1179
1244
  }
1180
1245
  return \`
1181
- <div class="set-panel" id="set-panel-\${name}">
1182
- <label>New value for <strong>\${name}</strong> — paste here, never in chat</label>
1183
- <textarea class="set-input" id="set-input-\${name}" placeholder="\${SERVICE_HINTS[name] || "Paste credential…"}" spellcheck="false"></textarea>
1184
- \${SERVICE_HINTS[name] ? \`<div style="font-size:.72rem;color:#475569;margin-top:4px;font-family:'Courier New',monospace">\${SERVICE_HINTS[name]}</div>\` : ""}
1246
+ <div class="set-panel" id="set-panel-\${id}">
1247
+ <label>New value for <strong>\${displayName}</strong> — paste here, never in chat</label>
1248
+ <textarea class="set-input" id="set-input-\${id}" placeholder="\${htmlEscape(SERVICE_HINTS[name] || "Paste credential…")}" spellcheck="false"></textarea>
1249
+ \${SERVICE_HINTS[name] ? \`<div style="font-size:.72rem;color:#475569;margin-top:4px;font-family:'Courier New',monospace">\${htmlEscape(SERVICE_HINTS[name])}</div>\` : ""}
1185
1250
  <div class="set-foot">
1186
- <button class="btn btn-save" onclick="saveKey('\${name}')">Save</button>
1187
- <button class="btn btn-cancel" onclick="toggleSet('\${name}')">Cancel</button>
1188
- <span class="set-msg" id="set-msg-\${name}"></span>
1251
+ <button class="btn btn-save" type="button" onclick='saveKey(\${arg})'>Save</button>
1252
+ <button class="btn btn-cancel" type="button" onclick='toggleSet(\${arg})'>Cancel</button>
1253
+ <span class="set-msg" id="set-msg-\${id}"></span>
1189
1254
  </div>
1190
1255
  </div>
1191
1256
  \`;
@@ -1312,8 +1377,17 @@ async function unlock() {
1312
1377
  input.disabled = true;
1313
1378
  btn.disabled = true;
1314
1379
  btn.textContent = "Unlock";
1315
- sub.textContent = "Too many failed attempts";
1316
- err.textContent = "✗ Vault locked restart daemon to try again";
1380
+ if (r.terminal) {
1381
+ // Server rendered a terminal verdict (invalid_token / machine_locked).
1382
+ // Show the reason — this is the one-line signal that diagnoses the lockout.
1383
+ sub.textContent = r.reason ? ("Vault rejected: " + r.reason) : "Vault rejected credentials";
1384
+ err.textContent = "✗ Recovery required — unlock machine + re-seal boot.key (see runbook)";
1385
+ } else {
1386
+ sub.textContent = "Too many failed attempts";
1387
+ err.textContent = r.reason
1388
+ ? ("✗ Vault locked (" + r.reason + ") — restart daemon to try again")
1389
+ : "✗ Vault locked — restart daemon to try again";
1390
+ }
1317
1391
  return;
1318
1392
  }
1319
1393
 
@@ -1626,60 +1700,64 @@ function renderServiceGrid(services) {
1626
1700
  : '<p class="loading">No services in this group.</p>';
1627
1701
  return;
1628
1702
  }
1629
- grid.innerHTML = filtered.map(s => \`
1703
+ grid.innerHTML = filtered.map(s => {
1704
+ const id = serviceDomId(s.name);
1705
+ const nameArg = jsArg(s.name);
1706
+ return \`
1630
1707
  <div class="card">
1631
1708
  <div style="display:flex;align-items:flex-start;justify-content:space-between">
1632
1709
  <div style="flex:1;min-width:0">
1633
1710
  <div style="display:flex;align-items:baseline;gap:8px;flex-wrap:wrap">
1634
- <span class="card-name" id="label-display-\${s.name}">\${s.label && s.label !== s.name ? s.label : s.name}</span>
1635
- <code style="font-family:'Courier New',monospace;font-size:.75rem;color:#64748b;background:rgba(100,116,139,.1);padding:1px 6px;border-radius:3px;letter-spacing:.3px">\${s.name}</code>
1711
+ <span class="card-name" id="label-display-\${id}">\${htmlEscape(s.label && s.label !== s.name ? s.label : s.name)}</span>
1712
+ <code style="font-family:'Courier New',monospace;font-size:.75rem;color:#64748b;background:rgba(100,116,139,.1);padding:1px 6px;border-radius:3px;letter-spacing:.3px">\${htmlEscape(s.name)}</code>
1636
1713
  </div>
1637
1714
  <div style="display:flex;align-items:center;gap:6px;margin-top:2px">
1638
- <div class="card-type">\${s.key_type || "secret"}</div>
1639
- <span class="svc-badge \${s.enabled === false ? "off" : "on"}" id="badge-\${s.name}">\${s.enabled === false ? "disabled" : "enabled"}</span>
1640
- <span class="expiry-badge" id="expiry-\${s.name}" style="font-size:.65rem;border-radius:3px;padding:1px 6px;display:none"></span>
1641
- \${s.project ? \`<span style="font-size:.68rem;color:#3b82f6;background:rgba(59,130,246,.1);border:1px solid rgba(59,130,246,.2);border-radius:3px;padding:1px 6px">\${s.project}</span>\` : ""}
1715
+ <div class="card-type">\${htmlEscape(s.key_type || "secret")}</div>
1716
+ <span class="svc-badge \${s.enabled === false ? "off" : "on"}" id="badge-\${id}">\${s.enabled === false ? "disabled" : "enabled"}</span>
1717
+ <span class="expiry-badge" id="expiry-\${id}" style="font-size:.65rem;border-radius:3px;padding:1px 6px;display:none"></span>
1718
+ \${s.project ? \`<span style="font-size:.68rem;color:#3b82f6;background:rgba(59,130,246,.1);border:1px solid rgba(59,130,246,.2);border-radius:3px;padding:1px 6px">\${htmlEscape(s.project)}</span>\` : ""}
1642
1719
  </div>
1643
- \${s.description ? \`<div style="font-size:.78rem;color:#64748b;margin-top:4px;line-height:1.3">\${s.description}</div>\` : ""}
1720
+ \${s.description ? \`<div style="font-size:.78rem;color:#64748b;margin-top:4px;line-height:1.3">\${htmlEscape(s.description)}</div>\` : ""}
1644
1721
  \${KEY_URLS[s.name] ? \`<a class="card-getkey" href="\${KEY_URLS[s.name]}" target="_blank" rel="noopener">↗ Get / rotate key</a>\` : ""}
1645
- \${(EXTRA_LINKS[s.name] || []).map(l => \`<a class="card-getkey" href="\${l.url}" target="_blank" rel="noopener" style="margin-left:0">\${l.label}</a>\`).join("")}
1722
+ \${(EXTRA_LINKS[s.name] || []).map(l => \`<a class="card-getkey" href="\${l.url}" target="_blank" rel="noopener" style="margin-left:0">\${htmlEscape(l.label)}</a>\`).join("")}
1646
1723
  </div>
1647
- <div class="status-dot" id="sdot-\${s.name}" title=""></div>
1724
+ <div class="status-dot" id="sdot-\${id}" title=""></div>
1648
1725
  </div>
1649
- <div class="card-value" id="val-\${s.name}"></div>
1726
+ <div class="card-value" id="val-\${id}"></div>
1650
1727
  <div class="card-actions">
1651
- <button class="btn btn-reveal" onclick="reveal('\${s.name}', this)">Reveal</button>
1652
- <button class="btn btn-copy" id="copybtn-\${s.name}" style="display:none" onclick="copyKey('\${s.name}')">Copy</button>
1653
- <button class="btn btn-set" onclick="toggleSet('\${s.name}')">Set</button>
1654
- <button class="btn-project" onclick="toggleProjectEdit('\${s.name}')">\${s.project ? "✎ Project" : "+ Project"}</button>
1655
- <button class="btn \${s.enabled === false ? "btn-enable" : "btn-disable"}" id="togbtn-\${s.name}" onclick="toggleService('\${s.name}')">\${s.enabled === false ? "Enable" : "Disable"}</button>
1656
- <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>
1657
- <button class="btn-rename" onclick="toggleLabelEdit('\${s.name}')" title="Edit display label">✏️</button>
1658
- <button class="btn-delete" onclick="deleteService('\${s.name}')" title="Delete service">✕</button>
1728
+ <button class="btn btn-reveal" type="button" onclick='reveal(\${nameArg}, this)'>Reveal</button>
1729
+ <button class="btn btn-copy" id="copybtn-\${id}" style="display:none" type="button" onclick='copyKey(\${nameArg})'>Copy</button>
1730
+ <button class="btn btn-set" type="button" onclick='toggleSet(\${nameArg})'>Set</button>
1731
+ <button class="btn-project" type="button" onclick='toggleProjectEdit(\${nameArg})'>\${s.project ? "✎ Project" : "+ Project"}</button>
1732
+ <button class="btn \${s.enabled === false ? "btn-enable" : "btn-disable"}" id="togbtn-\${id}" type="button" onclick='toggleService(\${nameArg})'>\${s.enabled === false ? "Enable" : "Disable"}</button>
1733
+ <button class="btn-rotate" id="rotbtn-\${id}" style="display:none;background:#0e7490;border:1px solid #06b6d4;color:#cffafe;font-size:.75rem;padding:3px 8px;border-radius:4px;cursor:pointer" type="button" onclick='rotateKey(\${nameArg})'>↻ Rotate</button>
1734
+ <button class="btn-rename" type="button" onclick='toggleLabelEdit(\${nameArg})' title="Edit display label">✏️</button>
1735
+ <button class="btn-delete" type="button" onclick='deleteService(\${nameArg})' title="Delete service">✕</button>
1659
1736
  </div>
1660
- <div class="rename-panel" id="rn-\${s.name}">
1737
+ <div class="rename-panel" id="rn-\${id}">
1661
1738
  <div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">
1662
1739
  <span style="font-size:.72rem;color:#64748b">Edit label for</span>
1663
- <code style="font-family:'Courier New',monospace;font-size:.75rem;color:#94a3b8;background:rgba(100,116,139,.1);padding:1px 5px;border-radius:3px">\${s.name}</code>
1740
+ <code style="font-family:'Courier New',monospace;font-size:.75rem;color:#94a3b8;background:rgba(100,116,139,.1);padding:1px 5px;border-radius:3px">\${htmlEscape(s.name)}</code>
1664
1741
  <span style="font-size:.68rem;color:#475569">(slug unchanged)</span>
1665
1742
  </div>
1666
1743
  <div style="display:flex;gap:6px;align-items:center">
1667
- <input class="rename-input" id="rn-input-\${s.name}" value="\${s.label || s.name}" spellcheck="false" autocomplete="off" placeholder="Human-readable label…"
1668
- onkeydown="if(event.key==='Enter')saveLabel('\${s.name}');if(event.key==='Escape')toggleLabelEdit('\${s.name}')">
1669
- <button class="btn" onclick="saveLabel('\${s.name}')" style="padding:4px 10px;font-size:.8rem">Save</button>
1670
- <button class="btn" onclick="toggleLabelEdit('\${s.name}')" style="padding:4px 10px;font-size:.8rem;background:#1e293b">Cancel</button>
1671
- <span class="rename-msg" id="rn-msg-\${s.name}"></span>
1744
+ <input class="rename-input" id="rn-input-\${id}" value="\${htmlEscape(s.label || s.name)}" spellcheck="false" autocomplete="off" placeholder="Human-readable label…"
1745
+ onkeydown='if(event.key==="Enter")saveLabel(\${nameArg});if(event.key==="Escape")toggleLabelEdit(\${nameArg})'>
1746
+ <button class="btn" type="button" onclick='saveLabel(\${nameArg})' style="padding:4px 10px;font-size:.8rem">Save</button>
1747
+ <button class="btn" type="button" onclick='toggleLabelEdit(\${nameArg})' style="padding:4px 10px;font-size:.8rem;background:#1e293b">Cancel</button>
1748
+ <span class="rename-msg" id="rn-msg-\${id}"></span>
1672
1749
  </div>
1673
1750
  </div>
1674
- <div class="project-edit" id="pe-\${s.name}">
1675
- <input type="text" id="pe-input-\${s.name}" value="\${s.project || ""}" placeholder="Project name…" spellcheck="false" autocomplete="off">
1676
- <button onclick="saveProject('\${s.name}')">Save</button>
1677
- <button onclick="clearProject('\${s.name}')">Clear</button>
1678
- <span class="pe-msg" id="pe-msg-\${s.name}"></span>
1751
+ <div class="project-edit" id="pe-\${id}">
1752
+ <input type="text" id="pe-input-\${id}" value="\${htmlEscape(s.project || "")}" placeholder="Project name…" spellcheck="false" autocomplete="off">
1753
+ <button type="button" onclick='saveProject(\${nameArg})'>Save</button>
1754
+ <button type="button" onclick='clearProject(\${nameArg})'>Clear</button>
1755
+ <span class="pe-msg" id="pe-msg-\${id}"></span>
1679
1756
  </div>
1680
- \${renderSetPanel(s.name)}
1757
+ \${renderSetPanel(s)}
1681
1758
  </div>
1682
- \`).join("");
1759
+ \`;
1760
+ }).join("");
1683
1761
  }
1684
1762
 
1685
1763
  async function loadServices() {
@@ -1733,8 +1811,9 @@ async function loadExpiry() {
1733
1811
  const data = await fetch(BASE + "/expiry").then(r => r.json());
1734
1812
  if (!data.services) return;
1735
1813
  for (const [name, info] of Object.entries(data.services)) {
1736
- const el = document.getElementById("expiry-" + name);
1737
- const rotBtn = document.getElementById("rotbtn-" + name);
1814
+ const id = serviceDomId(name);
1815
+ const el = document.getElementById("expiry-" + id);
1816
+ const rotBtn = document.getElementById("rotbtn-" + id);
1738
1817
  if (!el) continue;
1739
1818
 
1740
1819
  // Show rotate button for auto-capable services
@@ -1780,10 +1859,10 @@ async function loadExpiry() {
1780
1859
 
1781
1860
  async function rotateKey(name) {
1782
1861
  if (!confirm("Rotate " + name + " key?\\n\\nA new key will be created and the old one deleted.")) return;
1783
- const rotBtn = document.getElementById("rotbtn-" + name);
1862
+ const rotBtn = document.getElementById("rotbtn-" + serviceDomId(name));
1784
1863
  if (rotBtn) { rotBtn.disabled = true; rotBtn.textContent = "rotating…"; }
1785
1864
  try {
1786
- const r = await fetch(BASE + "/rotate/" + name, { method: "POST", headers: writeHeaders() }).then(r => r.json());
1865
+ const r = await fetch(BASE + "/rotate/" + encodeURIComponent(name), { method: "POST", headers: writeHeaders() }).then(r => r.json());
1787
1866
  if (r.ok) {
1788
1867
  if (rotBtn) { rotBtn.textContent = "✓ rotated"; rotBtn.style.background = "#166534"; }
1789
1868
  loadExpiry(); // refresh badges
@@ -1802,7 +1881,7 @@ async function setExpiry(name) {
1802
1881
  if (!days) return;
1803
1882
  const expiresAt = new Date(Date.now() + parseInt(days) * 86400000).toISOString();
1804
1883
  try {
1805
- await fetch(BASE + "/set-expiry/" + name, {
1884
+ await fetch(BASE + "/set-expiry/" + encodeURIComponent(name), {
1806
1885
  method: "POST",
1807
1886
  headers: writeHeaders({ "Content-Type": "application/json" }),
1808
1887
  body: JSON.stringify({ expires_at: expiresAt, rotation_days: parseInt(days) }),
@@ -1813,15 +1892,17 @@ async function setExpiry(name) {
1813
1892
 
1814
1893
  // ── Reveal ──────────────────────────────────
1815
1894
  async function reveal(name, btn) {
1816
- const valEl = document.getElementById("val-" + name);
1817
- const copyBtn = document.getElementById("copybtn-" + name);
1895
+ const id = serviceDomId(name);
1896
+ const valEl = document.getElementById("val-" + id);
1897
+ const copyBtn = document.getElementById("copybtn-" + id);
1898
+ if (!valEl || !copyBtn) return;
1818
1899
  if (valEl.style.display === "block") {
1819
1900
  valEl.style.display = "none"; copyBtn.style.display = "none";
1820
1901
  btn.textContent = "Reveal"; return;
1821
1902
  }
1822
1903
  valEl.textContent = "fetching…"; valEl.style.display = "block"; btn.textContent = "Hide";
1823
1904
  try {
1824
- const r = await fetch(BASE + "/get/" + name).then(r => r.json());
1905
+ const r = await fetch(BASE + "/get/" + encodeURIComponent(name)).then(r => r.json());
1825
1906
  if (r.locked) { showLockScreen(); return; }
1826
1907
  if (r.error) throw new Error(r.error);
1827
1908
  const imp = OAUTH_IMPORT[name];
@@ -1843,30 +1924,34 @@ async function reveal(name, btn) {
1843
1924
  }
1844
1925
 
1845
1926
  async function copyKey(name) {
1846
- const val = document.getElementById("val-" + name).textContent;
1927
+ const id = serviceDomId(name);
1928
+ const val = document.getElementById("val-" + id).textContent;
1847
1929
  try {
1848
1930
  await navigator.clipboard.writeText(val);
1849
- const btn = document.getElementById("copybtn-" + name);
1931
+ const btn = document.getElementById("copybtn-" + id);
1850
1932
  btn.textContent = "Copied!"; setTimeout(() => btn.textContent = "Copy", 1500);
1851
1933
  } catch {}
1852
1934
  }
1853
1935
 
1854
1936
  // ── Project edit ────────────────────────────
1855
1937
  function toggleProjectEdit(name) {
1856
- const panel = document.getElementById("pe-" + name);
1938
+ const id = serviceDomId(name);
1939
+ const panel = document.getElementById("pe-" + id);
1940
+ if (!panel) return;
1857
1941
  const open = panel.classList.contains("open");
1858
1942
  panel.classList.toggle("open");
1859
1943
  if (!open) {
1860
1944
  const svc = allServices.find(s => s.name === name);
1861
- document.getElementById("pe-input-" + name).value = svc ? (svc.project || "") : "";
1862
- document.getElementById("pe-msg-" + name).textContent = "";
1863
- document.getElementById("pe-input-" + name).focus();
1945
+ document.getElementById("pe-input-" + id).value = svc ? (svc.project || "") : "";
1946
+ document.getElementById("pe-msg-" + id).textContent = "";
1947
+ document.getElementById("pe-input-" + id).focus();
1864
1948
  }
1865
1949
  }
1866
1950
 
1867
1951
  async function saveProject(name) {
1868
- const input = document.getElementById("pe-input-" + name);
1869
- const msg = document.getElementById("pe-msg-" + name);
1952
+ const id = serviceDomId(name);
1953
+ const input = document.getElementById("pe-input-" + id);
1954
+ const msg = document.getElementById("pe-msg-" + id);
1870
1955
  const project = input.value.trim();
1871
1956
  msg.textContent = "Saving…"; msg.style.color = "#94a3b8";
1872
1957
  try {
@@ -1888,27 +1973,30 @@ async function saveProject(name) {
1888
1973
  }
1889
1974
 
1890
1975
  async function clearProject(name) {
1891
- document.getElementById("pe-input-" + name).value = "";
1976
+ document.getElementById("pe-input-" + serviceDomId(name)).value = "";
1892
1977
  await saveProject(name);
1893
1978
  }
1894
1979
 
1895
1980
  // ── Rename service ───────────────────────────
1896
1981
  function toggleLabelEdit(name) {
1897
- const panel = document.getElementById("rn-" + name);
1982
+ const id = serviceDomId(name);
1983
+ const panel = document.getElementById("rn-" + id);
1984
+ if (!panel) return;
1898
1985
  const open = panel.style.display === "block";
1899
1986
  panel.style.display = open ? "none" : "block";
1900
1987
  if (!open) {
1901
- const inp = document.getElementById("rn-input-" + name);
1988
+ const inp = document.getElementById("rn-input-" + id);
1902
1989
  const svc = allServices.find(s => s.name === name);
1903
1990
  inp.value = (svc && svc.label) ? svc.label : name;
1904
1991
  inp.focus(); inp.select();
1905
- document.getElementById("rn-msg-" + name).textContent = "";
1992
+ document.getElementById("rn-msg-" + id).textContent = "";
1906
1993
  }
1907
1994
  }
1908
1995
 
1909
1996
  async function saveLabel(name) {
1910
- const inp = document.getElementById("rn-input-" + name);
1911
- const msg = document.getElementById("rn-msg-" + name);
1997
+ const id = serviceDomId(name);
1998
+ const inp = document.getElementById("rn-input-" + id);
1999
+ const msg = document.getElementById("rn-msg-" + id);
1912
2000
  const newLabel = inp.value.trim();
1913
2001
  if (!newLabel) { toggleLabelEdit(name); return; }
1914
2002
  const svc = allServices.find(s => s.name === name);
@@ -1925,7 +2013,7 @@ async function saveLabel(name) {
1925
2013
  msg.style.color = "#4ade80"; msg.textContent = "✓ Label updated";
1926
2014
  // Update in-memory and DOM immediately
1927
2015
  if (svc) svc.label = newLabel;
1928
- const labelEl = document.getElementById("label-display-" + name);
2016
+ const labelEl = document.getElementById("label-display-" + id);
1929
2017
  if (labelEl) labelEl.textContent = newLabel;
1930
2018
  setTimeout(() => { toggleLabelEdit(name); }, 800);
1931
2019
  } catch (e) {
@@ -1941,7 +2029,7 @@ async function saveRename(oldName) { await saveLabel(oldName); }
1941
2029
  async function deleteService(name) {
1942
2030
  if (!confirm(\`Delete service "\${name}"? This cannot be undone.\`)) return;
1943
2031
  try {
1944
- const r = await fetch(BASE + "/delete/" + name, { method: "POST", headers: writeHeaders() }).then(r => r.json());
2032
+ const r = await fetch(BASE + "/delete/" + encodeURIComponent(name), { method: "POST", headers: writeHeaders() }).then(r => r.json());
1945
2033
  if (r.locked) { showLockScreen(false); return; }
1946
2034
  if (r.error) throw new Error(r.error);
1947
2035
  loadServices();
@@ -1952,31 +2040,46 @@ async function deleteService(name) {
1952
2040
 
1953
2041
  // ── Set key ─────────────────────────────────
1954
2042
  function toggleSet(name) {
1955
- const panel = document.getElementById("set-panel-" + name);
1956
- const msg = document.getElementById("set-msg-" + name);
2043
+ const id = serviceDomId(name);
2044
+ const panel = document.getElementById("set-panel-" + id);
2045
+ const msg = document.getElementById("set-msg-" + id);
2046
+ if (!panel) return;
1957
2047
  const open = panel.style.display === "block";
1958
2048
  panel.style.display = open ? "none" : "block";
1959
2049
  if (!open) {
1960
2050
  if (msg) msg.textContent = "";
1961
2051
  const imp = OAUTH_IMPORT[name];
1962
2052
  if (imp) {
1963
- const jsonEl = document.getElementById("ofield-" + name + "-json");
2053
+ const jsonEl = document.getElementById("ofield-" + id + "-json");
1964
2054
  if (jsonEl) { jsonEl.value = ""; jsonEl.focus(); }
1965
- imp.extra.forEach(f => { const el = document.getElementById("ofield-" + name + "-" + f.key); if (el) el.value = ""; });
2055
+ imp.extra.forEach(f => { const el = document.getElementById("ofield-" + id + "-" + f.key); if (el) el.value = ""; });
2056
+ } else if (getServiceKeyType(name) === "keypair") {
2057
+ const keyEl = document.getElementById("kp-key-" + id);
2058
+ const valueEl = document.getElementById("kp-value-" + id);
2059
+ if (keyEl) { keyEl.value = ""; keyEl.focus(); }
2060
+ if (valueEl) valueEl.value = "";
1966
2061
  } else {
1967
- const input = document.getElementById("set-input-" + name);
2062
+ const input = document.getElementById("set-input-" + id);
1968
2063
  if (input) { input.value = ""; input.focus(); }
1969
2064
  }
1970
2065
  }
1971
2066
  }
1972
2067
 
2068
+ function getServiceKeyType(name) {
2069
+ const svc = allServices.find(s => s.name === name);
2070
+ return String(svc?.key_type || "").toLowerCase();
2071
+ }
2072
+
1973
2073
  async function saveKey(name) {
1974
- const msg = document.getElementById("set-msg-" + name);
2074
+ const id = serviceDomId(name);
2075
+ const msg = document.getElementById("set-msg-" + id);
2076
+ if (!msg) return;
1975
2077
  const imp = OAUTH_IMPORT[name];
2078
+ const keyType = getServiceKeyType(name);
1976
2079
  let value;
1977
2080
 
1978
2081
  if (imp) {
1979
- const jsonEl = document.getElementById("ofield-" + name + "-json");
2082
+ const jsonEl = document.getElementById("ofield-" + id + "-json");
1980
2083
  const raw = jsonEl ? jsonEl.value.trim() : "";
1981
2084
  if (!raw) { msg.className = "set-msg fail"; msg.textContent = "Paste the OAuth JSON first."; return; }
1982
2085
  let parsed;
@@ -1989,21 +2092,29 @@ async function saveKey(name) {
1989
2092
  obj[k] = src[k];
1990
2093
  }
1991
2094
  for (const f of imp.extra) {
1992
- const el = document.getElementById("ofield-" + name + "-" + f.key);
2095
+ const el = document.getElementById("ofield-" + id + "-" + f.key);
1993
2096
  const v = el ? el.value.trim() : "";
1994
2097
  if (!v) { msg.className = "set-msg fail"; msg.textContent = f.label + " is required."; return; }
1995
2098
  obj[f.key] = v;
1996
2099
  }
1997
2100
  value = JSON.stringify(obj);
2101
+ } else if (keyType === "keypair") {
2102
+ const keyEl = document.getElementById("kp-key-" + id);
2103
+ const valueEl = document.getElementById("kp-value-" + id);
2104
+ const user = keyEl ? keyEl.value.trim() : "";
2105
+ const pass = valueEl ? valueEl.value : "";
2106
+ if (!user) { msg.className = "set-msg fail"; msg.textContent = "User / key is required."; return; }
2107
+ if (!pass.trim()) { msg.className = "set-msg fail"; msg.textContent = "Password / secret is required."; return; }
2108
+ value = JSON.stringify({ user, pass, username: user, password: pass, key: user, value: pass });
1998
2109
  } else {
1999
- const input = document.getElementById("set-input-" + name);
2110
+ const input = document.getElementById("set-input-" + id);
2000
2111
  value = input ? input.value : "";
2001
2112
  if (!value.trim()) { msg.className = "set-msg fail"; msg.textContent = "Value is empty."; return; }
2002
2113
  }
2003
2114
 
2004
2115
  msg.className = "set-msg"; msg.textContent = "Saving…";
2005
2116
  try {
2006
- const r = await fetch(BASE + "/set/" + name, {
2117
+ const r = await fetch(BASE + "/set/" + encodeURIComponent(name), {
2007
2118
  method: "POST",
2008
2119
  headers: writeHeaders({ "Content-Type": "application/json" }),
2009
2120
  body: JSON.stringify({ value })
@@ -2013,17 +2124,23 @@ async function saveKey(name) {
2013
2124
  if (r.error) throw new Error(r.error);
2014
2125
  msg.className = "set-msg ok"; msg.textContent = "✓ Saved";
2015
2126
  if (imp) {
2016
- const jsonEl = document.getElementById("ofield-" + name + "-json");
2127
+ const jsonEl = document.getElementById("ofield-" + id + "-json");
2017
2128
  if (jsonEl) jsonEl.value = "";
2018
- imp.extra.forEach(f => { const el = document.getElementById("ofield-" + name + "-" + f.key); if (el) el.value = ""; });
2129
+ imp.extra.forEach(f => { const el = document.getElementById("ofield-" + id + "-" + f.key); if (el) el.value = ""; });
2130
+ } else if (keyType === "keypair") {
2131
+ const keyEl = document.getElementById("kp-key-" + id);
2132
+ const valueEl = document.getElementById("kp-value-" + id);
2133
+ if (keyEl) keyEl.value = "";
2134
+ if (valueEl) valueEl.value = "";
2019
2135
  } else {
2020
- const inp = document.getElementById("set-input-" + name);
2136
+ const inp = document.getElementById("set-input-" + id);
2021
2137
  if (inp) inp.value = "";
2022
2138
  }
2023
- const dot = document.getElementById("sdot-" + name);
2139
+ const dot = document.getElementById("sdot-" + id);
2024
2140
  if (dot) { dot.className = "status-dot"; dot.title = ""; }
2025
2141
  setTimeout(() => {
2026
- document.getElementById("set-panel-" + name).style.display = "none";
2142
+ const panel = document.getElementById("set-panel-" + id);
2143
+ if (panel) panel.style.display = "none";
2027
2144
  msg.textContent = "";
2028
2145
  }, 1800);
2029
2146
  } catch (e) {
@@ -2033,15 +2150,17 @@ async function saveKey(name) {
2033
2150
 
2034
2151
  // ── Enable / Disable service ────────────────
2035
2152
  async function toggleService(name) {
2036
- const badge = document.getElementById("badge-" + name);
2037
- const btn = document.getElementById("togbtn-" + name);
2153
+ const id = serviceDomId(name);
2154
+ const badge = document.getElementById("badge-" + id);
2155
+ const btn = document.getElementById("togbtn-" + id);
2156
+ if (!badge || !btn) return;
2038
2157
  const currently = badge.classList.contains("on");
2039
2158
  const newState = !currently;
2040
2159
 
2041
2160
  btn.disabled = true; btn.textContent = "…";
2042
2161
 
2043
2162
  try {
2044
- const r = await fetch(BASE + "/toggle/" + name, {
2163
+ const r = await fetch(BASE + "/toggle/" + encodeURIComponent(name), {
2045
2164
  method: "POST",
2046
2165
  headers: writeHeaders({ "Content-Type": "application/json" }),
2047
2166
  body: JSON.stringify({ enabled: newState })
@@ -2097,7 +2216,7 @@ async function checkAll() {
2097
2216
  }
2098
2217
  continue;
2099
2218
  }
2100
- const dot = document.getElementById("sdot-" + name);
2219
+ const dot = document.getElementById("sdot-" + serviceDomId(name));
2101
2220
  if (!dot) continue;
2102
2221
  if (result.ok) {
2103
2222
  dot.className = "status-dot ok"; dot.title = "OK";
@@ -5282,7 +5401,13 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
5282
5401
  try {
5283
5402
  const { token, timestamp } = deriveToken(pw, machineHash);
5284
5403
  const result = await api.test(pw, machineHash, token, timestamp);
5285
- if (result.error) throw new Error(result.error);
5404
+ if (result.error) {
5405
+ // Preserve the server's `reason` (invalid_token (N/5), machine_locked, …)
5406
+ // so the catch block can surface it AND decide whether to keep retrying.
5407
+ const authError = new Error(result.error);
5408
+ authError.serverReason = result.reason || null;
5409
+ throw authError;
5410
+ }
5286
5411
  password = pw; // unlock — store in process memory only
5287
5412
  writeSession = makeWriteToken();
5288
5413
  authFailCount = 0;
@@ -5342,26 +5467,42 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
5342
5467
  return ok(res, { ok: true, locked: false, write_token: writeSession.token, write_expires_at: new Date(writeSession.expiresAt).toISOString() });
5343
5468
  } catch (authErr) {
5344
5469
  const msg = authErr.message || "";
5470
+ const serverReason = authErr.serverReason || null;
5345
5471
  const isTransport = msg.includes("fetch failed") || msg.includes("ECONNREFUSED") || msg.includes("ETIMEDOUT") || msg.includes("ENOTFOUND") || msg.includes("network");
5346
5472
  if (isTransport) {
5473
+ // Transport failure — the vault never rendered a verdict. Safe to retry; no strike.
5347
5474
  const failLog = `[${new Date().toISOString()}] Vault unreachable: ${msg}\n`;
5348
5475
  try { fs.appendFileSync(LOG_FILE, failLog); } catch {}
5349
5476
  res.writeHead(503, { "Content-Type": "application/json", ...CORS });
5350
5477
  return res.end(JSON.stringify({ error: "Vault backend is unreachable — try again in a moment", transport_error: true, detail: msg }));
5351
5478
  }
5479
+ // Server rendered a verdict. Surface its `reason` so the CLI/log shows
5480
+ // WHY (invalid_token (N/5) vs machine_locked) instead of a bare "auth_failed".
5481
+ const reasonSuffix = serverReason ? ` (${serverReason})` : "";
5482
+ // STOP-ON-REJECTION: a server-side machine lock or invalid_token is terminal —
5483
+ // each retry only burns another of the 5 server-side strikes toward a DB lockout.
5484
+ // Hard-lock locally NOW so the browser/CLI cannot keep hammering the vault.
5485
+ const isTerminalVerdict = isTerminalAuthVerdict(serverReason);
5352
5486
  authFailCount++;
5353
5487
  const authRemaining = MAX_AUTH_FAILS - authFailCount;
5354
- const failLog = `[${new Date().toISOString()}] [AUTH FAIL ${authFailCount}/${MAX_AUTH_FAILS}] ${msg || "Wrong password"}\n`;
5488
+ const failLog = `[${new Date().toISOString()}] [AUTH FAIL ${authFailCount}/${MAX_AUTH_FAILS}] ${msg || "Wrong password"}${reasonSuffix}\n`;
5355
5489
  try { fs.appendFileSync(LOG_FILE, failLog); } catch {}
5490
+ if (isTerminalVerdict) {
5491
+ authHardLocked = true;
5492
+ const lockLog = `[${new Date().toISOString()}] Server rejected with terminal verdict${reasonSuffix} — hard-locking locally to stop strike accrual; recover via the runbook (unlock machine + re-seal boot.key)\n`;
5493
+ try { fs.appendFileSync(LOG_FILE, lockLog); } catch {}
5494
+ res.writeHead(401, { "Content-Type": "application/json", ...CORS });
5495
+ return res.end(JSON.stringify({ error: "Vault rejected credentials — recovery required", reason: serverReason, hard_locked: true, terminal: true }));
5496
+ }
5356
5497
  if (authFailCount >= MAX_AUTH_FAILS) {
5357
5498
  authHardLocked = true;
5358
5499
  const lockLog = `[${new Date().toISOString()}] Auth failure limit reached — vault hard-locked\n`;
5359
5500
  try { fs.appendFileSync(LOG_FILE, lockLog); } catch {}
5360
5501
  res.writeHead(401, { "Content-Type": "application/json", ...CORS });
5361
- return res.end(JSON.stringify({ error: "Too many failed attempts — restart daemon to try again", hard_locked: true }));
5502
+ return res.end(JSON.stringify({ error: "Too many failed attempts — restart daemon to try again", reason: serverReason, hard_locked: true }));
5362
5503
  }
5363
5504
  res.writeHead(401, { "Content-Type": "application/json", ...CORS });
5364
- return res.end(JSON.stringify({ error: "Invalid password", failures_remaining: authRemaining }));
5505
+ return res.end(JSON.stringify({ error: "Invalid password", reason: serverReason, failures_remaining: authRemaining }));
5365
5506
  }
5366
5507
  }
5367
5508
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lifeaitools/clauth",
3
- "version": "1.9.1",
3
+ "version": "1.9.3",
4
4
  "description": "Hardware-bound credential vault for the LIFEAI infrastructure stack",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,6 +8,7 @@
8
8
  },
9
9
  "scripts": {
10
10
  "build": "bash scripts/build.sh",
11
+ "test": "node test-auth-verdict.mjs",
11
12
  "postinstall": "node scripts/postinstall.js",
12
13
  "worker:start": "node cli/index.js serve",
13
14
  "worker:stop": "curl -s http://127.0.0.1:52437/shutdown 2>nul || taskkill /F /IM cloudflared.exe 2>nul & exit 0",
Binary file
Binary file
Binary file
@@ -12,36 +12,36 @@ const ALLOWED_IPS: string[] = (Deno.env.get("CLAUTH_ALLOWED_IPS") || "")
12
12
  .split(",").map(s => s.trim()).filter(Boolean);
13
13
 
14
14
  const RATE_LIMIT_MAX = 30;
15
- const RATE_LIMIT_WINDOW = 60;
16
- const REPLAY_WINDOW_MS = 5 * 60 * 1000;
17
- const MAX_FAIL_COUNT = 5;
18
- const DEFAULT_INSTALL_ID = "default";
15
+ const RATE_LIMIT_WINDOW = 60;
16
+ const REPLAY_WINDOW_MS = 5 * 60 * 1000;
17
+ const MAX_FAIL_COUNT = 5;
18
+ const DEFAULT_INSTALL_ID = "default";
19
19
 
20
- async function hmacSha256(key: string, message: string): Promise<string> {
20
+ async function hmacSha256(key: string, message: string): Promise<string> {
21
21
  const cryptoKey = await crypto.subtle.importKey(
22
22
  "raw", new TextEncoder().encode(key),
23
23
  { name: "HMAC", hash: "SHA-256" }, false, ["sign"]
24
24
  );
25
25
  const sig = await crypto.subtle.sign("HMAC", cryptoKey, new TextEncoder().encode(message));
26
26
  return Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, "0")).join("");
27
- }
28
-
29
- async function sha256Hex(value: string): Promise<string> {
30
- const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
31
- return Array.from(new Uint8Array(digest)).map(b => b.toString(16).padStart(2, "0")).join("");
32
- }
33
-
34
- function randomToken(bytes = 24): string {
35
- const data = new Uint8Array(bytes);
36
- crypto.getRandomValues(data);
37
- return Array.from(data).map(b => b.toString(16).padStart(2, "0")).join("");
38
- }
39
-
40
- function normalizeInstallId(value: unknown): string {
41
- const text = String(value || "").trim().toLowerCase();
42
- const normalized = text.replace(/[^a-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "");
43
- return normalized || DEFAULT_INSTALL_ID;
44
- }
27
+ }
28
+
29
+ async function sha256Hex(value: string): Promise<string> {
30
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
31
+ return Array.from(new Uint8Array(digest)).map(b => b.toString(16).padStart(2, "0")).join("");
32
+ }
33
+
34
+ function randomToken(bytes = 24): string {
35
+ const data = new Uint8Array(bytes);
36
+ crypto.getRandomValues(data);
37
+ return Array.from(data).map(b => b.toString(16).padStart(2, "0")).join("");
38
+ }
39
+
40
+ function normalizeInstallId(value: unknown): string {
41
+ const text = String(value || "").trim().toLowerCase();
42
+ const normalized = text.replace(/[^a-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "");
43
+ return normalized || DEFAULT_INSTALL_ID;
44
+ }
45
45
 
46
46
  function getClientIP(req: Request): string {
47
47
  return req.headers.get("cf-connecting-ip") ||
@@ -71,23 +71,23 @@ async function validateHMAC(sb: any, body: any): Promise<{ valid: boolean; reaso
71
71
  const now = Date.now();
72
72
  if (Math.abs(now - body.timestamp) > REPLAY_WINDOW_MS) return { valid: false, reason: "timestamp_expired" };
73
73
 
74
- const { data: machine, error } = await sb.from("clauth_machines")
75
- .select("hmac_seed_hash, enabled, fail_count, locked")
76
- .eq("machine_hash", body.machine_hash).single();
74
+ const { data: machine, error } = await sb.from("clauth_machines")
75
+ .select("hmac_seed_hash, enabled, fail_count, locked")
76
+ .eq("machine_hash", body.machine_hash).single();
77
77
 
78
78
  if (error || !machine) return { valid: false, reason: "machine_not_found" };
79
79
  if (!machine.enabled) return { valid: false, reason: "machine_disabled" };
80
80
  if (machine.locked) return { valid: false, reason: "machine_locked" };
81
81
 
82
82
  const window = Math.floor(body.timestamp / REPLAY_WINDOW_MS);
83
- const message = `${body.machine_hash}:${window}`;
84
- const expected = await hmacSha256(body.password, message);
85
- const seedHash = await sha256Hex(`seed:${body.machine_hash}:${body.password}`);
86
-
87
- if (seedHash !== machine.hmac_seed_hash || expected !== body.token) {
88
- const newCount = (machine.fail_count || 0) + 1;
89
- const shouldLock = newCount >= MAX_FAIL_COUNT;
90
- await sb.from("clauth_machines")
83
+ const message = `${body.machine_hash}:${window}`;
84
+ const expected = await hmacSha256(body.password, message);
85
+ const seedHash = await sha256Hex(`seed:${body.machine_hash}:${body.password}`);
86
+
87
+ if (seedHash !== machine.hmac_seed_hash || expected !== body.token) {
88
+ const newCount = (machine.fail_count || 0) + 1;
89
+ const shouldLock = newCount >= MAX_FAIL_COUNT;
90
+ await sb.from("clauth_machines")
91
91
  .update({ fail_count: newCount, locked: shouldLock })
92
92
  .eq("machine_hash", body.machine_hash);
93
93
  return { valid: false, reason: shouldLock ? `machine_locked after ${newCount} failures` : `invalid_token (${newCount}/${MAX_FAIL_COUNT})` };
@@ -197,7 +197,7 @@ async function handleStatus(sb: any, body: any, mh: string) {
197
197
  return { services: services || [] };
198
198
  }
199
199
 
200
- async function handleChangePassword(sb: any, body: any, mh: string) {
200
+ async function handleChangePassword(sb: any, body: any, mh: string) {
201
201
  const { new_hmac_seed_hash } = body;
202
202
  if (!new_hmac_seed_hash) return { error: "new_hmac_seed_hash required" };
203
203
  const { error } = await sb.from("clauth_machines")
@@ -206,91 +206,92 @@ async function handleChangePassword(sb: any, body: any, mh: string) {
206
206
  if (error) return { error: error.message };
207
207
  await auditLog(sb, mh, "system", "change-password", "success");
208
208
  return { success: true };
209
- }
210
-
211
- async function getMachineInstallId(sb: any, machine_hash: string): Promise<string> {
212
- const { data: machine } = await sb.from("clauth_machines")
213
- .select("install_id")
214
- .eq("machine_hash", machine_hash)
215
- .single();
216
- return normalizeInstallId(machine?.install_id);
217
- }
218
-
219
- async function handleCreateEnrollment(sb: any, body: any, mh: string) {
220
- const install_id = normalizeInstallId(body.install_id || await getMachineInstallId(sb, mh));
221
- const ttl_minutes = Math.max(5, Math.min(Number(body.ttl_minutes || 60), 24 * 60));
222
- const code = `ce_${randomToken(24)}`;
223
- const token_hash = await sha256Hex(code);
224
- const expires_at = new Date(Date.now() + ttl_minutes * 60 * 1000).toISOString();
225
- const label = body.label ? String(body.label).slice(0, 120) : null;
226
-
227
- const { error } = await sb.from("clauth_machine_enrollments").insert({
228
- install_id,
229
- token_hash,
230
- label,
231
- created_by_machine_hash: mh,
232
- expires_at,
233
- });
234
- if (error) {
235
- await auditLog(sb, mh, "system", "create-enrollment", "fail", error.message);
236
- return { error: error.message };
237
- }
238
-
239
- await auditLog(sb, mh, "system", "create-enrollment", "success", `install_id=${install_id}`);
240
- return { success: true, enrollment_code: code, install_id, expires_at, label };
241
- }
242
-
243
- async function handleRedeemEnrollment(sb: any, body: any) {
244
- const { machine_hash, hmac_seed_hash, enrollment_code } = body;
245
- if (!machine_hash || !hmac_seed_hash || !enrollment_code) {
246
- return { error: "machine_hash, hmac_seed_hash, enrollment_code required" };
247
- }
248
-
249
- const token_hash = await sha256Hex(String(enrollment_code).trim());
250
- const nowIso = new Date().toISOString();
251
- const { data: enrollment, error: lookupError } = await sb.from("clauth_machine_enrollments")
252
- .select("id, install_id, label, expires_at, consumed_at")
253
- .eq("token_hash", token_hash)
254
- .single();
255
-
256
- if (lookupError || !enrollment) return { error: "enrollment_not_found" };
257
- if (enrollment.consumed_at) return { error: "enrollment_already_used" };
258
- if (new Date(enrollment.expires_at).getTime() < Date.now()) return { error: "enrollment_expired" };
259
-
260
- const label = body.label || enrollment.label || null;
261
- const install_id = normalizeInstallId(enrollment.install_id);
262
- const { data: consumedRows, error: consumeError } = await sb.from("clauth_machine_enrollments")
263
- .update({ consumed_at: nowIso, redeemed_by_machine_hash: machine_hash })
264
- .eq("id", enrollment.id)
265
- .is("consumed_at", null)
266
- .select("id");
267
- if (consumeError) return { error: consumeError.message };
268
- if (!consumedRows || consumedRows.length !== 1) return { error: "enrollment_already_used" };
269
-
270
- const { error: machineError } = await sb.from("clauth_machines").upsert(
271
- { machine_hash, hmac_seed_hash, label, install_id, enabled: true, fail_count: 0, locked: false },
272
- { onConflict: "machine_hash" }
273
- );
274
- if (machineError) return { error: machineError.message };
275
-
276
- await auditLog(sb, machine_hash, "system", "redeem-enrollment", "success", `install_id=${install_id}`);
277
- return { success: true, machine_hash, install_id };
278
- }
279
-
280
- async function handleRegisterMachine(sb: any, body: any) {
281
- const { machine_hash, hmac_seed_hash, label, admin_token } = body;
282
- if (body.enrollment_code || body.invite_code) {
283
- return handleRedeemEnrollment(sb, { ...body, enrollment_code: body.enrollment_code || body.invite_code });
284
- }
285
- if (admin_token !== ADMIN_BOOTSTRAP_TOKEN) return { error: "invalid_admin_token" };
286
- const install_id = normalizeInstallId(body.install_id);
287
- const { error } = await sb.from("clauth_machines").upsert(
288
- { machine_hash, hmac_seed_hash, label, install_id, enabled: true, fail_count: 0, locked: false },
289
- { onConflict: "machine_hash" }
290
- );
291
- if (error) return { error: error.message };
292
- return { success: true, machine_hash, install_id };
293
- }
209
+ }
210
+
211
+ async function getMachineInstallId(sb: any, machine_hash: string): Promise<string> {
212
+ const { data: machine } = await sb.from("clauth_machines")
213
+ .select("install_id")
214
+ .eq("machine_hash", machine_hash)
215
+ .single();
216
+ return normalizeInstallId(machine?.install_id);
217
+ }
218
+
219
+ async function handleCreateEnrollment(sb: any, body: any, mh: string) {
220
+ const install_id = normalizeInstallId(body.install_id || await getMachineInstallId(sb, mh));
221
+ const ttl_minutes = Math.max(5, Math.min(Number(body.ttl_minutes || 60), 24 * 60));
222
+ const code = `ce_${randomToken(24)}`;
223
+ const token_hash = await sha256Hex(code);
224
+ const expires_at = new Date(Date.now() + ttl_minutes * 60 * 1000).toISOString();
225
+ const label = body.label ? String(body.label).slice(0, 120) : null;
226
+
227
+ const { error } = await sb.from("clauth_machine_enrollments").insert({
228
+ install_id,
229
+ token_hash,
230
+ label,
231
+ created_by_machine_hash: mh,
232
+ expires_at,
233
+ });
234
+ if (error) {
235
+ await auditLog(sb, mh, "system", "create-enrollment", "fail", error.message);
236
+ return { error: error.message };
237
+ }
238
+
239
+ await auditLog(sb, mh, "system", "create-enrollment", "success", `install_id=${install_id}`);
240
+ return { success: true, enrollment_code: code, install_id, expires_at, label };
241
+ }
242
+
243
+ async function handleRedeemEnrollment(sb: any, body: any) {
244
+ const { machine_hash, hmac_seed_hash, enrollment_code } = body;
245
+ if (!machine_hash || !hmac_seed_hash || !enrollment_code) {
246
+ return { error: "machine_hash, hmac_seed_hash, enrollment_code required" };
247
+ }
248
+
249
+ const token_hash = await sha256Hex(String(enrollment_code).trim());
250
+ const nowIso = new Date().toISOString();
251
+ const { data: enrollment, error: lookupError } = await sb.from("clauth_machine_enrollments")
252
+ .select("id, install_id, label, expires_at, consumed_at")
253
+ .eq("token_hash", token_hash)
254
+ .single();
255
+
256
+ if (lookupError || !enrollment) return { error: "enrollment_not_found" };
257
+ if (enrollment.consumed_at) return { error: "enrollment_already_used" };
258
+ if (new Date(enrollment.expires_at).getTime() < Date.now()) return { error: "enrollment_expired" };
259
+
260
+ const label = body.label || enrollment.label || null;
261
+ const install_id = normalizeInstallId(enrollment.install_id);
262
+
263
+ const { error: machineError } = await sb.from("clauth_machines").upsert(
264
+ { machine_hash, hmac_seed_hash, label, install_id, enabled: true, fail_count: 0, locked: false },
265
+ { onConflict: "machine_hash" }
266
+ );
267
+ if (machineError) return { error: machineError.message };
268
+
269
+ const { data: consumedRows, error: consumeError } = await sb.from("clauth_machine_enrollments")
270
+ .update({ consumed_at: nowIso, redeemed_by_machine_hash: machine_hash })
271
+ .eq("id", enrollment.id)
272
+ .is("consumed_at", null)
273
+ .select("id");
274
+ if (consumeError) return { error: consumeError.message };
275
+ if (!consumedRows || consumedRows.length !== 1) return { error: "enrollment_already_used" };
276
+
277
+ await auditLog(sb, machine_hash, "system", "redeem-enrollment", "success", `install_id=${install_id}`);
278
+ return { success: true, machine_hash, install_id };
279
+ }
280
+
281
+ async function handleRegisterMachine(sb: any, body: any) {
282
+ const { machine_hash, hmac_seed_hash, label, admin_token } = body;
283
+ if (body.enrollment_code || body.invite_code) {
284
+ return handleRedeemEnrollment(sb, { ...body, enrollment_code: body.enrollment_code || body.invite_code });
285
+ }
286
+ if (admin_token !== ADMIN_BOOTSTRAP_TOKEN) return { error: "invalid_admin_token" };
287
+ const install_id = normalizeInstallId(body.install_id);
288
+ const { error } = await sb.from("clauth_machines").upsert(
289
+ { machine_hash, hmac_seed_hash, label, install_id, enabled: true, fail_count: 0, locked: false },
290
+ { onConflict: "machine_hash" }
291
+ );
292
+ if (error) return { error: error.message };
293
+ return { success: true, machine_hash, install_id };
294
+ }
294
295
 
295
296
  Deno.serve(async (req: Request) => {
296
297
  if (req.method === "OPTIONS") {
@@ -308,8 +309,8 @@ Deno.serve(async (req: Request) => {
308
309
  const sb = createClient(SUPABASE_URL, SERVICE_ROLE_KEY);
309
310
  const ip = getClientIP(req);
310
311
 
311
- if (route === "register-machine") return Response.json(await handleRegisterMachine(sb, body));
312
- if (route === "redeem-enrollment") return Response.json(await handleRedeemEnrollment(sb, body));
312
+ if (route === "register-machine") return Response.json(await handleRegisterMachine(sb, body));
313
+ if (route === "redeem-enrollment") return Response.json(await handleRedeemEnrollment(sb, body));
313
314
 
314
315
  const ipCheck = checkIP(ip);
315
316
  if (!ipCheck.allowed) {
@@ -340,10 +341,10 @@ Deno.serve(async (req: Request) => {
340
341
  case "update": return Response.json(await handleUpdate(sb, body, mh));
341
342
  case "remove": return Response.json(await handleRemove(sb, body, mh));
342
343
  case "revoke": return Response.json(await handleRevoke(sb, body, mh));
343
- case "status": return Response.json(await handleStatus(sb, body, mh));
344
- case "change-password": return Response.json(await handleChangePassword(sb, body, mh));
345
- case "create-enrollment": return Response.json(await handleCreateEnrollment(sb, body, mh));
346
- case "test": return Response.json({ valid: true, machine_hash: mh, timestamp: body.timestamp, ip });
344
+ case "status": return Response.json(await handleStatus(sb, body, mh));
345
+ case "change-password": return Response.json(await handleChangePassword(sb, body, mh));
346
+ case "create-enrollment": return Response.json(await handleCreateEnrollment(sb, body, mh));
347
+ case "test": return Response.json({ valid: true, machine_hash: mh, timestamp: body.timestamp, ip });
347
348
  default: return Response.json({ error: "unknown_route", route }, { status: 404 });
348
349
  }
349
350
  });