@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.
package/cli/commands/serve.js
CHANGED
|
@@ -1148,44 +1148,93 @@ const OAUTH_IMPORT = {
|
|
|
1148
1148
|
}
|
|
1149
1149
|
};
|
|
1150
1150
|
|
|
1151
|
-
function
|
|
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, "<")
|
|
1159
|
+
.replace(/>/g, ">")
|
|
1160
|
+
.replace(/"/g, """)
|
|
1161
|
+
.replace(/'/g, "'");
|
|
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-\${
|
|
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-\${
|
|
1163
|
-
<label>Set <strong>\${
|
|
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-\${
|
|
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=
|
|
1174
|
-
<button class="btn btn-cancel" onclick=
|
|
1175
|
-
<span class="set-msg" id="set-msg-\${
|
|
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-\${
|
|
1182
|
-
<label>New value for <strong>\${
|
|
1183
|
-
<textarea class="set-input" id="set-input-\${
|
|
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=
|
|
1187
|
-
<button class="btn btn-cancel" onclick=
|
|
1188
|
-
<span class="set-msg" id="set-msg-\${
|
|
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-\${
|
|
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-\${
|
|
1640
|
-
<span class="expiry-badge" id="expiry-\${
|
|
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-\${
|
|
1699
|
+
<div class="status-dot" id="sdot-\${id}" title=""></div>
|
|
1648
1700
|
</div>
|
|
1649
|
-
<div class="card-value" id="val-\${
|
|
1701
|
+
<div class="card-value" id="val-\${id}"></div>
|
|
1650
1702
|
<div class="card-actions">
|
|
1651
|
-
<button class="btn btn-reveal" onclick=
|
|
1652
|
-
<button class="btn btn-copy" id="copybtn-\${
|
|
1653
|
-
<button class="btn btn-set" onclick=
|
|
1654
|
-
<button class="btn-project" onclick=
|
|
1655
|
-
<button class="btn \${s.enabled === false ? "btn-enable" : "btn-disable"}" id="togbtn-\${
|
|
1656
|
-
<button class="btn-rotate" id="rotbtn-\${
|
|
1657
|
-
<button class="btn-rename" onclick=
|
|
1658
|
-
<button class="btn-delete" onclick=
|
|
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-\${
|
|
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-\${
|
|
1668
|
-
onkeydown=
|
|
1669
|
-
<button class="btn" onclick=
|
|
1670
|
-
<button class="btn" onclick=
|
|
1671
|
-
<span class="rename-msg" id="rn-msg-\${
|
|
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-\${
|
|
1675
|
-
<input type="text" id="pe-input-\${
|
|
1676
|
-
<button onclick=
|
|
1677
|
-
<button onclick=
|
|
1678
|
-
<span class="pe-msg" id="pe-msg-\${
|
|
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
|
|
1732
|
+
\${renderSetPanel(s)}
|
|
1681
1733
|
</div>
|
|
1682
|
-
|
|
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
|
|
1737
|
-
const
|
|
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
|
|
1817
|
-
const
|
|
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
|
|
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-" +
|
|
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
|
|
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-" +
|
|
1862
|
-
document.getElementById("pe-msg-" +
|
|
1863
|
-
document.getElementById("pe-input-" +
|
|
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
|
|
1869
|
-
const
|
|
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
|
|
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-" +
|
|
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-" +
|
|
1967
|
+
document.getElementById("rn-msg-" + id).textContent = "";
|
|
1906
1968
|
}
|
|
1907
1969
|
}
|
|
1908
1970
|
|
|
1909
1971
|
async function saveLabel(name) {
|
|
1910
|
-
const
|
|
1911
|
-
const
|
|
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-" +
|
|
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
|
|
1956
|
-
const
|
|
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-" +
|
|
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-" +
|
|
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-" +
|
|
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
|
|
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-" +
|
|
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-" +
|
|
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-" +
|
|
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-" +
|
|
2102
|
+
const jsonEl = document.getElementById("ofield-" + id + "-json");
|
|
2017
2103
|
if (jsonEl) jsonEl.value = "";
|
|
2018
|
-
imp.extra.forEach(f => { const el = document.getElementById("ofield-" +
|
|
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-" +
|
|
2111
|
+
const inp = document.getElementById("set-input-" + id);
|
|
2021
2112
|
if (inp) inp.value = "";
|
|
2022
2113
|
}
|
|
2023
|
-
const dot = document.getElementById("sdot-" +
|
|
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-" +
|
|
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
|
|
2037
|
-
const
|
|
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
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
if (
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
if (
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
{
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
return {
|
|
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
|
});
|