@lifeaitools/clauth 1.9.1 → 1.9.2

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.
@@ -1148,44 +1148,93 @@ const OAUTH_IMPORT = {
1148
1148
  }
1149
1149
  };
1150
1150
 
1151
- function renderSetPanel(name) {
1151
+ function serviceDomId(name) {
1152
+ return encodeURIComponent(String(name)).replace(/%/g, "_");
1153
+ }
1154
+
1155
+ function htmlEscape(value) {
1156
+ return String(value ?? "")
1157
+ .replace(/&/g, "&")
1158
+ .replace(/</g, "&lt;")
1159
+ .replace(/>/g, "&gt;")
1160
+ .replace(/"/g, "&quot;")
1161
+ .replace(/'/g, "&#39;");
1162
+ }
1163
+
1164
+ function jsArg(value) {
1165
+ return JSON.stringify(String(value ?? ""))
1166
+ .replace(/</g, "\\u003c")
1167
+ .replace(/>/g, "\\u003e")
1168
+ .replace(/&/g, "\\u0026")
1169
+ .replace(/'/g, "\\u0027");
1170
+ }
1171
+
1172
+ function renderSetPanel(serviceOrName) {
1173
+ const service = typeof serviceOrName === "object" && serviceOrName ? serviceOrName : { name: serviceOrName };
1174
+ const name = service.name;
1175
+ const keyType = String(service.key_type || "").toLowerCase();
1176
+ const id = serviceDomId(name);
1177
+ const arg = jsArg(name);
1178
+ const displayName = htmlEscape(name);
1152
1179
  const imp = OAUTH_IMPORT[name];
1153
1180
  if (imp) {
1154
1181
  const extraHtml = imp.extra.map(f => \`
1155
1182
  <div class="oauth-field">
1156
1183
  <label class="oauth-label">\${f.label}</label>
1157
1184
  \${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">
1185
+ <input type="text" class="oauth-input" id="ofield-\${id}-\${f.key}" placeholder="Paste \${htmlEscape(f.label)}…" spellcheck="false" autocomplete="off">
1159
1186
  </div>
1160
1187
  \`).join("");
1161
1188
  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>
1189
+ <div class="set-panel" id="set-panel-\${id}">
1190
+ <label>Set <strong>\${displayName}</strong> credentials — paste directly from Google, never in chat</label>
1164
1191
  <div class="oauth-fields">
1165
1192
  <div class="oauth-field">
1166
1193
  <label class="oauth-label">OAuth JSON from Google Cloud Console</label>
1167
1194
  <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>
1195
+ <textarea class="set-input" id="ofield-\${id}-json" placeholder='{"installed":{"client_id":"…","client_secret":"…",...}}' spellcheck="false" rows="3"></textarea>
1169
1196
  </div>
1170
1197
  \${extraHtml}
1171
1198
  </div>
1172
1199
  <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>
1200
+ <button class="btn btn-save" type="button" onclick='saveKey(\${arg})'>Save</button>
1201
+ <button class="btn btn-cancel" type="button" onclick='toggleSet(\${arg})'>Cancel</button>
1202
+ <span class="set-msg" id="set-msg-\${id}"></span>
1203
+ </div>
1204
+ </div>
1205
+ \`;
1206
+ }
1207
+ if (keyType === "keypair") {
1208
+ return \`
1209
+ <div class="set-panel" id="set-panel-\${id}">
1210
+ <label>New user/pass pair for <strong>\${displayName}</strong> — paste here, never in chat</label>
1211
+ <div class="oauth-fields">
1212
+ <div class="oauth-field">
1213
+ <label class="oauth-label">User / Key</label>
1214
+ <input type="text" class="oauth-input" id="kp-key-\${id}" placeholder="Username, key id, access key id…" spellcheck="false" autocomplete="off">
1215
+ </div>
1216
+ <div class="oauth-field">
1217
+ <label class="oauth-label">Password / Secret</label>
1218
+ <textarea class="set-input" id="kp-value-\${id}" placeholder="Password, API key, private key, secret…" spellcheck="false" rows="3"></textarea>
1219
+ </div>
1220
+ </div>
1221
+ <div class="set-foot">
1222
+ <button class="btn btn-save" type="button" onclick='saveKey(\${arg})'>Save</button>
1223
+ <button class="btn btn-cancel" type="button" onclick='toggleSet(\${arg})'>Cancel</button>
1224
+ <span class="set-msg" id="set-msg-\${id}"></span>
1176
1225
  </div>
1177
1226
  </div>
1178
1227
  \`;
1179
1228
  }
1180
1229
  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>\` : ""}
1230
+ <div class="set-panel" id="set-panel-\${id}">
1231
+ <label>New value for <strong>\${displayName}</strong> — paste here, never in chat</label>
1232
+ <textarea class="set-input" id="set-input-\${id}" placeholder="\${htmlEscape(SERVICE_HINTS[name] || "Paste credential…")}" spellcheck="false"></textarea>
1233
+ \${SERVICE_HINTS[name] ? \`<div style="font-size:.72rem;color:#475569;margin-top:4px;font-family:'Courier New',monospace">\${htmlEscape(SERVICE_HINTS[name])}</div>\` : ""}
1185
1234
  <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>
1235
+ <button class="btn btn-save" type="button" onclick='saveKey(\${arg})'>Save</button>
1236
+ <button class="btn btn-cancel" type="button" onclick='toggleSet(\${arg})'>Cancel</button>
1237
+ <span class="set-msg" id="set-msg-\${id}"></span>
1189
1238
  </div>
1190
1239
  </div>
1191
1240
  \`;
@@ -1626,60 +1675,64 @@ function renderServiceGrid(services) {
1626
1675
  : '<p class="loading">No services in this group.</p>';
1627
1676
  return;
1628
1677
  }
1629
- grid.innerHTML = filtered.map(s => \`
1678
+ grid.innerHTML = filtered.map(s => {
1679
+ const id = serviceDomId(s.name);
1680
+ const nameArg = jsArg(s.name);
1681
+ return \`
1630
1682
  <div class="card">
1631
1683
  <div style="display:flex;align-items:flex-start;justify-content:space-between">
1632
1684
  <div style="flex:1;min-width:0">
1633
1685
  <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>
1686
+ <span class="card-name" id="label-display-\${id}">\${htmlEscape(s.label && s.label !== s.name ? s.label : s.name)}</span>
1687
+ <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
1688
  </div>
1637
1689
  <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>\` : ""}
1690
+ <div class="card-type">\${htmlEscape(s.key_type || "secret")}</div>
1691
+ <span class="svc-badge \${s.enabled === false ? "off" : "on"}" id="badge-\${id}">\${s.enabled === false ? "disabled" : "enabled"}</span>
1692
+ <span class="expiry-badge" id="expiry-\${id}" style="font-size:.65rem;border-radius:3px;padding:1px 6px;display:none"></span>
1693
+ \${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
1694
  </div>
1643
- \${s.description ? \`<div style="font-size:.78rem;color:#64748b;margin-top:4px;line-height:1.3">\${s.description}</div>\` : ""}
1695
+ \${s.description ? \`<div style="font-size:.78rem;color:#64748b;margin-top:4px;line-height:1.3">\${htmlEscape(s.description)}</div>\` : ""}
1644
1696
  \${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("")}
1697
+ \${(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
1698
  </div>
1647
- <div class="status-dot" id="sdot-\${s.name}" title=""></div>
1699
+ <div class="status-dot" id="sdot-\${id}" title=""></div>
1648
1700
  </div>
1649
- <div class="card-value" id="val-\${s.name}"></div>
1701
+ <div class="card-value" id="val-\${id}"></div>
1650
1702
  <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>
1703
+ <button class="btn btn-reveal" type="button" onclick='reveal(\${nameArg}, this)'>Reveal</button>
1704
+ <button class="btn btn-copy" id="copybtn-\${id}" style="display:none" type="button" onclick='copyKey(\${nameArg})'>Copy</button>
1705
+ <button class="btn btn-set" type="button" onclick='toggleSet(\${nameArg})'>Set</button>
1706
+ <button class="btn-project" type="button" onclick='toggleProjectEdit(\${nameArg})'>\${s.project ? "✎ Project" : "+ Project"}</button>
1707
+ <button class="btn \${s.enabled === false ? "btn-enable" : "btn-disable"}" id="togbtn-\${id}" type="button" onclick='toggleService(\${nameArg})'>\${s.enabled === false ? "Enable" : "Disable"}</button>
1708
+ <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>
1709
+ <button class="btn-rename" type="button" onclick='toggleLabelEdit(\${nameArg})' title="Edit display label">✏️</button>
1710
+ <button class="btn-delete" type="button" onclick='deleteService(\${nameArg})' title="Delete service">✕</button>
1659
1711
  </div>
1660
- <div class="rename-panel" id="rn-\${s.name}">
1712
+ <div class="rename-panel" id="rn-\${id}">
1661
1713
  <div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">
1662
1714
  <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>
1715
+ <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
1716
  <span style="font-size:.68rem;color:#475569">(slug unchanged)</span>
1665
1717
  </div>
1666
1718
  <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>
1719
+ <input class="rename-input" id="rn-input-\${id}" value="\${htmlEscape(s.label || s.name)}" spellcheck="false" autocomplete="off" placeholder="Human-readable label…"
1720
+ onkeydown='if(event.key==="Enter")saveLabel(\${nameArg});if(event.key==="Escape")toggleLabelEdit(\${nameArg})'>
1721
+ <button class="btn" type="button" onclick='saveLabel(\${nameArg})' style="padding:4px 10px;font-size:.8rem">Save</button>
1722
+ <button class="btn" type="button" onclick='toggleLabelEdit(\${nameArg})' style="padding:4px 10px;font-size:.8rem;background:#1e293b">Cancel</button>
1723
+ <span class="rename-msg" id="rn-msg-\${id}"></span>
1672
1724
  </div>
1673
1725
  </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>
1726
+ <div class="project-edit" id="pe-\${id}">
1727
+ <input type="text" id="pe-input-\${id}" value="\${htmlEscape(s.project || "")}" placeholder="Project name…" spellcheck="false" autocomplete="off">
1728
+ <button type="button" onclick='saveProject(\${nameArg})'>Save</button>
1729
+ <button type="button" onclick='clearProject(\${nameArg})'>Clear</button>
1730
+ <span class="pe-msg" id="pe-msg-\${id}"></span>
1679
1731
  </div>
1680
- \${renderSetPanel(s.name)}
1732
+ \${renderSetPanel(s)}
1681
1733
  </div>
1682
- \`).join("");
1734
+ \`;
1735
+ }).join("");
1683
1736
  }
1684
1737
 
1685
1738
  async function loadServices() {
@@ -1733,8 +1786,9 @@ async function loadExpiry() {
1733
1786
  const data = await fetch(BASE + "/expiry").then(r => r.json());
1734
1787
  if (!data.services) return;
1735
1788
  for (const [name, info] of Object.entries(data.services)) {
1736
- const el = document.getElementById("expiry-" + name);
1737
- const rotBtn = document.getElementById("rotbtn-" + name);
1789
+ const id = serviceDomId(name);
1790
+ const el = document.getElementById("expiry-" + id);
1791
+ const rotBtn = document.getElementById("rotbtn-" + id);
1738
1792
  if (!el) continue;
1739
1793
 
1740
1794
  // Show rotate button for auto-capable services
@@ -1780,10 +1834,10 @@ async function loadExpiry() {
1780
1834
 
1781
1835
  async function rotateKey(name) {
1782
1836
  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);
1837
+ const rotBtn = document.getElementById("rotbtn-" + serviceDomId(name));
1784
1838
  if (rotBtn) { rotBtn.disabled = true; rotBtn.textContent = "rotating…"; }
1785
1839
  try {
1786
- const r = await fetch(BASE + "/rotate/" + name, { method: "POST", headers: writeHeaders() }).then(r => r.json());
1840
+ const r = await fetch(BASE + "/rotate/" + encodeURIComponent(name), { method: "POST", headers: writeHeaders() }).then(r => r.json());
1787
1841
  if (r.ok) {
1788
1842
  if (rotBtn) { rotBtn.textContent = "✓ rotated"; rotBtn.style.background = "#166534"; }
1789
1843
  loadExpiry(); // refresh badges
@@ -1802,7 +1856,7 @@ async function setExpiry(name) {
1802
1856
  if (!days) return;
1803
1857
  const expiresAt = new Date(Date.now() + parseInt(days) * 86400000).toISOString();
1804
1858
  try {
1805
- await fetch(BASE + "/set-expiry/" + name, {
1859
+ await fetch(BASE + "/set-expiry/" + encodeURIComponent(name), {
1806
1860
  method: "POST",
1807
1861
  headers: writeHeaders({ "Content-Type": "application/json" }),
1808
1862
  body: JSON.stringify({ expires_at: expiresAt, rotation_days: parseInt(days) }),
@@ -1813,15 +1867,17 @@ async function setExpiry(name) {
1813
1867
 
1814
1868
  // ── Reveal ──────────────────────────────────
1815
1869
  async function reveal(name, btn) {
1816
- const valEl = document.getElementById("val-" + name);
1817
- const copyBtn = document.getElementById("copybtn-" + name);
1870
+ const id = serviceDomId(name);
1871
+ const valEl = document.getElementById("val-" + id);
1872
+ const copyBtn = document.getElementById("copybtn-" + id);
1873
+ if (!valEl || !copyBtn) return;
1818
1874
  if (valEl.style.display === "block") {
1819
1875
  valEl.style.display = "none"; copyBtn.style.display = "none";
1820
1876
  btn.textContent = "Reveal"; return;
1821
1877
  }
1822
1878
  valEl.textContent = "fetching…"; valEl.style.display = "block"; btn.textContent = "Hide";
1823
1879
  try {
1824
- const r = await fetch(BASE + "/get/" + name).then(r => r.json());
1880
+ const r = await fetch(BASE + "/get/" + encodeURIComponent(name)).then(r => r.json());
1825
1881
  if (r.locked) { showLockScreen(); return; }
1826
1882
  if (r.error) throw new Error(r.error);
1827
1883
  const imp = OAUTH_IMPORT[name];
@@ -1843,30 +1899,34 @@ async function reveal(name, btn) {
1843
1899
  }
1844
1900
 
1845
1901
  async function copyKey(name) {
1846
- const val = document.getElementById("val-" + name).textContent;
1902
+ const id = serviceDomId(name);
1903
+ const val = document.getElementById("val-" + id).textContent;
1847
1904
  try {
1848
1905
  await navigator.clipboard.writeText(val);
1849
- const btn = document.getElementById("copybtn-" + name);
1906
+ const btn = document.getElementById("copybtn-" + id);
1850
1907
  btn.textContent = "Copied!"; setTimeout(() => btn.textContent = "Copy", 1500);
1851
1908
  } catch {}
1852
1909
  }
1853
1910
 
1854
1911
  // ── Project edit ────────────────────────────
1855
1912
  function toggleProjectEdit(name) {
1856
- const panel = document.getElementById("pe-" + name);
1913
+ const id = serviceDomId(name);
1914
+ const panel = document.getElementById("pe-" + id);
1915
+ if (!panel) return;
1857
1916
  const open = panel.classList.contains("open");
1858
1917
  panel.classList.toggle("open");
1859
1918
  if (!open) {
1860
1919
  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();
1920
+ document.getElementById("pe-input-" + id).value = svc ? (svc.project || "") : "";
1921
+ document.getElementById("pe-msg-" + id).textContent = "";
1922
+ document.getElementById("pe-input-" + id).focus();
1864
1923
  }
1865
1924
  }
1866
1925
 
1867
1926
  async function saveProject(name) {
1868
- const input = document.getElementById("pe-input-" + name);
1869
- const msg = document.getElementById("pe-msg-" + name);
1927
+ const id = serviceDomId(name);
1928
+ const input = document.getElementById("pe-input-" + id);
1929
+ const msg = document.getElementById("pe-msg-" + id);
1870
1930
  const project = input.value.trim();
1871
1931
  msg.textContent = "Saving…"; msg.style.color = "#94a3b8";
1872
1932
  try {
@@ -1888,27 +1948,30 @@ async function saveProject(name) {
1888
1948
  }
1889
1949
 
1890
1950
  async function clearProject(name) {
1891
- document.getElementById("pe-input-" + name).value = "";
1951
+ document.getElementById("pe-input-" + serviceDomId(name)).value = "";
1892
1952
  await saveProject(name);
1893
1953
  }
1894
1954
 
1895
1955
  // ── Rename service ───────────────────────────
1896
1956
  function toggleLabelEdit(name) {
1897
- const panel = document.getElementById("rn-" + name);
1957
+ const id = serviceDomId(name);
1958
+ const panel = document.getElementById("rn-" + id);
1959
+ if (!panel) return;
1898
1960
  const open = panel.style.display === "block";
1899
1961
  panel.style.display = open ? "none" : "block";
1900
1962
  if (!open) {
1901
- const inp = document.getElementById("rn-input-" + name);
1963
+ const inp = document.getElementById("rn-input-" + id);
1902
1964
  const svc = allServices.find(s => s.name === name);
1903
1965
  inp.value = (svc && svc.label) ? svc.label : name;
1904
1966
  inp.focus(); inp.select();
1905
- document.getElementById("rn-msg-" + name).textContent = "";
1967
+ document.getElementById("rn-msg-" + id).textContent = "";
1906
1968
  }
1907
1969
  }
1908
1970
 
1909
1971
  async function saveLabel(name) {
1910
- const inp = document.getElementById("rn-input-" + name);
1911
- const msg = document.getElementById("rn-msg-" + name);
1972
+ const id = serviceDomId(name);
1973
+ const inp = document.getElementById("rn-input-" + id);
1974
+ const msg = document.getElementById("rn-msg-" + id);
1912
1975
  const newLabel = inp.value.trim();
1913
1976
  if (!newLabel) { toggleLabelEdit(name); return; }
1914
1977
  const svc = allServices.find(s => s.name === name);
@@ -1925,7 +1988,7 @@ async function saveLabel(name) {
1925
1988
  msg.style.color = "#4ade80"; msg.textContent = "✓ Label updated";
1926
1989
  // Update in-memory and DOM immediately
1927
1990
  if (svc) svc.label = newLabel;
1928
- const labelEl = document.getElementById("label-display-" + name);
1991
+ const labelEl = document.getElementById("label-display-" + id);
1929
1992
  if (labelEl) labelEl.textContent = newLabel;
1930
1993
  setTimeout(() => { toggleLabelEdit(name); }, 800);
1931
1994
  } catch (e) {
@@ -1941,7 +2004,7 @@ async function saveRename(oldName) { await saveLabel(oldName); }
1941
2004
  async function deleteService(name) {
1942
2005
  if (!confirm(\`Delete service "\${name}"? This cannot be undone.\`)) return;
1943
2006
  try {
1944
- const r = await fetch(BASE + "/delete/" + name, { method: "POST", headers: writeHeaders() }).then(r => r.json());
2007
+ const r = await fetch(BASE + "/delete/" + encodeURIComponent(name), { method: "POST", headers: writeHeaders() }).then(r => r.json());
1945
2008
  if (r.locked) { showLockScreen(false); return; }
1946
2009
  if (r.error) throw new Error(r.error);
1947
2010
  loadServices();
@@ -1952,31 +2015,46 @@ async function deleteService(name) {
1952
2015
 
1953
2016
  // ── Set key ─────────────────────────────────
1954
2017
  function toggleSet(name) {
1955
- const panel = document.getElementById("set-panel-" + name);
1956
- const msg = document.getElementById("set-msg-" + name);
2018
+ const id = serviceDomId(name);
2019
+ const panel = document.getElementById("set-panel-" + id);
2020
+ const msg = document.getElementById("set-msg-" + id);
2021
+ if (!panel) return;
1957
2022
  const open = panel.style.display === "block";
1958
2023
  panel.style.display = open ? "none" : "block";
1959
2024
  if (!open) {
1960
2025
  if (msg) msg.textContent = "";
1961
2026
  const imp = OAUTH_IMPORT[name];
1962
2027
  if (imp) {
1963
- const jsonEl = document.getElementById("ofield-" + name + "-json");
2028
+ const jsonEl = document.getElementById("ofield-" + id + "-json");
1964
2029
  if (jsonEl) { jsonEl.value = ""; jsonEl.focus(); }
1965
- imp.extra.forEach(f => { const el = document.getElementById("ofield-" + name + "-" + f.key); if (el) el.value = ""; });
2030
+ imp.extra.forEach(f => { const el = document.getElementById("ofield-" + id + "-" + f.key); if (el) el.value = ""; });
2031
+ } else if (getServiceKeyType(name) === "keypair") {
2032
+ const keyEl = document.getElementById("kp-key-" + id);
2033
+ const valueEl = document.getElementById("kp-value-" + id);
2034
+ if (keyEl) { keyEl.value = ""; keyEl.focus(); }
2035
+ if (valueEl) valueEl.value = "";
1966
2036
  } else {
1967
- const input = document.getElementById("set-input-" + name);
2037
+ const input = document.getElementById("set-input-" + id);
1968
2038
  if (input) { input.value = ""; input.focus(); }
1969
2039
  }
1970
2040
  }
1971
2041
  }
1972
2042
 
2043
+ function getServiceKeyType(name) {
2044
+ const svc = allServices.find(s => s.name === name);
2045
+ return String(svc?.key_type || "").toLowerCase();
2046
+ }
2047
+
1973
2048
  async function saveKey(name) {
1974
- const msg = document.getElementById("set-msg-" + name);
2049
+ const id = serviceDomId(name);
2050
+ const msg = document.getElementById("set-msg-" + id);
2051
+ if (!msg) return;
1975
2052
  const imp = OAUTH_IMPORT[name];
2053
+ const keyType = getServiceKeyType(name);
1976
2054
  let value;
1977
2055
 
1978
2056
  if (imp) {
1979
- const jsonEl = document.getElementById("ofield-" + name + "-json");
2057
+ const jsonEl = document.getElementById("ofield-" + id + "-json");
1980
2058
  const raw = jsonEl ? jsonEl.value.trim() : "";
1981
2059
  if (!raw) { msg.className = "set-msg fail"; msg.textContent = "Paste the OAuth JSON first."; return; }
1982
2060
  let parsed;
@@ -1989,21 +2067,29 @@ async function saveKey(name) {
1989
2067
  obj[k] = src[k];
1990
2068
  }
1991
2069
  for (const f of imp.extra) {
1992
- const el = document.getElementById("ofield-" + name + "-" + f.key);
2070
+ const el = document.getElementById("ofield-" + id + "-" + f.key);
1993
2071
  const v = el ? el.value.trim() : "";
1994
2072
  if (!v) { msg.className = "set-msg fail"; msg.textContent = f.label + " is required."; return; }
1995
2073
  obj[f.key] = v;
1996
2074
  }
1997
2075
  value = JSON.stringify(obj);
2076
+ } else if (keyType === "keypair") {
2077
+ const keyEl = document.getElementById("kp-key-" + id);
2078
+ const valueEl = document.getElementById("kp-value-" + id);
2079
+ const user = keyEl ? keyEl.value.trim() : "";
2080
+ const pass = valueEl ? valueEl.value : "";
2081
+ if (!user) { msg.className = "set-msg fail"; msg.textContent = "User / key is required."; return; }
2082
+ if (!pass.trim()) { msg.className = "set-msg fail"; msg.textContent = "Password / secret is required."; return; }
2083
+ value = JSON.stringify({ user, pass, username: user, password: pass, key: user, value: pass });
1998
2084
  } else {
1999
- const input = document.getElementById("set-input-" + name);
2085
+ const input = document.getElementById("set-input-" + id);
2000
2086
  value = input ? input.value : "";
2001
2087
  if (!value.trim()) { msg.className = "set-msg fail"; msg.textContent = "Value is empty."; return; }
2002
2088
  }
2003
2089
 
2004
2090
  msg.className = "set-msg"; msg.textContent = "Saving…";
2005
2091
  try {
2006
- const r = await fetch(BASE + "/set/" + name, {
2092
+ const r = await fetch(BASE + "/set/" + encodeURIComponent(name), {
2007
2093
  method: "POST",
2008
2094
  headers: writeHeaders({ "Content-Type": "application/json" }),
2009
2095
  body: JSON.stringify({ value })
@@ -2013,17 +2099,23 @@ async function saveKey(name) {
2013
2099
  if (r.error) throw new Error(r.error);
2014
2100
  msg.className = "set-msg ok"; msg.textContent = "✓ Saved";
2015
2101
  if (imp) {
2016
- const jsonEl = document.getElementById("ofield-" + name + "-json");
2102
+ const jsonEl = document.getElementById("ofield-" + id + "-json");
2017
2103
  if (jsonEl) jsonEl.value = "";
2018
- imp.extra.forEach(f => { const el = document.getElementById("ofield-" + name + "-" + f.key); if (el) el.value = ""; });
2104
+ imp.extra.forEach(f => { const el = document.getElementById("ofield-" + id + "-" + f.key); if (el) el.value = ""; });
2105
+ } else if (keyType === "keypair") {
2106
+ const keyEl = document.getElementById("kp-key-" + id);
2107
+ const valueEl = document.getElementById("kp-value-" + id);
2108
+ if (keyEl) keyEl.value = "";
2109
+ if (valueEl) valueEl.value = "";
2019
2110
  } else {
2020
- const inp = document.getElementById("set-input-" + name);
2111
+ const inp = document.getElementById("set-input-" + id);
2021
2112
  if (inp) inp.value = "";
2022
2113
  }
2023
- const dot = document.getElementById("sdot-" + name);
2114
+ const dot = document.getElementById("sdot-" + id);
2024
2115
  if (dot) { dot.className = "status-dot"; dot.title = ""; }
2025
2116
  setTimeout(() => {
2026
- document.getElementById("set-panel-" + name).style.display = "none";
2117
+ const panel = document.getElementById("set-panel-" + id);
2118
+ if (panel) panel.style.display = "none";
2027
2119
  msg.textContent = "";
2028
2120
  }, 1800);
2029
2121
  } catch (e) {
@@ -2033,15 +2125,17 @@ async function saveKey(name) {
2033
2125
 
2034
2126
  // ── Enable / Disable service ────────────────
2035
2127
  async function toggleService(name) {
2036
- const badge = document.getElementById("badge-" + name);
2037
- const btn = document.getElementById("togbtn-" + name);
2128
+ const id = serviceDomId(name);
2129
+ const badge = document.getElementById("badge-" + id);
2130
+ const btn = document.getElementById("togbtn-" + id);
2131
+ if (!badge || !btn) return;
2038
2132
  const currently = badge.classList.contains("on");
2039
2133
  const newState = !currently;
2040
2134
 
2041
2135
  btn.disabled = true; btn.textContent = "…";
2042
2136
 
2043
2137
  try {
2044
- const r = await fetch(BASE + "/toggle/" + name, {
2138
+ const r = await fetch(BASE + "/toggle/" + encodeURIComponent(name), {
2045
2139
  method: "POST",
2046
2140
  headers: writeHeaders({ "Content-Type": "application/json" }),
2047
2141
  body: JSON.stringify({ enabled: newState })
@@ -2097,7 +2191,7 @@ async function checkAll() {
2097
2191
  }
2098
2192
  continue;
2099
2193
  }
2100
- const dot = document.getElementById("sdot-" + name);
2194
+ const dot = document.getElementById("sdot-" + serviceDomId(name));
2101
2195
  if (!dot) continue;
2102
2196
  if (result.ok) {
2103
2197
  dot.className = "status-dot ok"; dot.title = "OK";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lifeaitools/clauth",
3
- "version": "1.9.1",
3
+ "version": "1.9.2",
4
4
  "description": "Hardware-bound credential vault for the LIFEAI infrastructure stack",
5
5
  "type": "module",
6
6
  "bin": {
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
  });