const dropzone = document.getElementById("dropzone"); const fileInput = document.getElementById("file-input"); const pluginList = document.getElementById("plugin-list"); const refreshBtn = document.getElementById("refresh-btn"); const uploadStatus = document.getElementById("upload-status"); const PLUGIN_CACHE_INVALIDATION_KEY = "overlord_plugin_cache_invalidated_at"; function notifyPluginsChanged() { try { localStorage.setItem(PLUGIN_CACHE_INVALIDATION_KEY, String(Date.now())); } catch {} window.dispatchEvent(new CustomEvent("overlord:plugins-changed")); } async function checkAuth() { try { const res = await fetch("/api/auth/me", { credentials: "include" }); if (!res.ok) { window.location.href = "/"; return; } const data = await res.json(); const usernameDisplay = document.getElementById("username-display"); const roleBadge = document.getElementById("role-badge"); if (usernameDisplay) { usernameDisplay.textContent = data.username; } if (roleBadge) { const roleBadges = { admin: 'Admin', operator: 'Operator', viewer: 'Viewer', }; if (roleBadges[data.role]) { roleBadge.innerHTML = roleBadges[data.role]; } else { roleBadge.textContent = data.role || ""; } if (data.role === "admin") { roleBadge.classList.add( "bg-purple-900/50", "text-purple-300", "border", "border-purple-800", ); } else if (data.role === "operator") { roleBadge.classList.add( "bg-blue-900/50", "text-blue-300", "border", "border-blue-800", ); } else { roleBadge.classList.add( "bg-slate-700", "text-slate-300", "border", "border-slate-600", ); } } if (data.role === "admin") { document.getElementById("build-link")?.classList.remove("hidden"); document.getElementById("users-link")?.classList.remove("hidden"); document.getElementById("plugins-link")?.classList.remove("hidden"); document.getElementById("deploy-link")?.classList.remove("hidden"); } else if (data.role === "operator" || data.canBuild) { document.getElementById("build-link")?.classList.remove("hidden"); } if (data.role !== "viewer") { document.getElementById("scripts-link")?.classList.remove("hidden"); } } catch (err) { console.error("Auth check failed:", err); window.location.href = "/"; } } function setStatus(text, isError = false) { uploadStatus.textContent = text; uploadStatus.className = `mt-3 text-sm ${isError ? "text-red-400" : "text-slate-400"}`; } async function fetchPlugins() { const res = await fetch("/api/plugins"); if (!res.ok) { setStatus("Failed to load plugins", true); return []; } const data = await res.json(); return Array.isArray(data.plugins) ? data.plugins : []; } function getTrustBadge(sig) { if (!sig) return { icon: "fa-shield-halved", color: "text-orange-400 border-orange-600 bg-orange-900/30", label: "Unsigned", tooltip: "This plugin is not signed" }; if (sig.signed && !sig.valid) return { icon: "fa-shield-xmark", color: "text-red-400 border-red-600 bg-red-900/30", label: "Invalid", tooltip: "Signature verification failed — plugin may be tampered" }; if (sig.signed && sig.valid && sig.trusted) return { icon: "fa-shield-check", color: "text-emerald-400 border-emerald-600 bg-emerald-900/30", label: "Trusted", tooltip: `Signed by trusted key: ${sig.fingerprint || "unknown"}` }; if (sig.signed && sig.valid && !sig.trusted) return { icon: "fa-shield", color: "text-yellow-400 border-yellow-600 bg-yellow-900/30", label: "Untrusted", tooltip: `Signed but key not trusted: ${sig.fingerprint || "unknown"}` }; return { icon: "fa-shield-halved", color: "text-orange-400 border-orange-600 bg-orange-900/30", label: "Unsigned", tooltip: "This plugin is not signed" }; } function describeNeeds(needs) { const files = Array.isArray(needs?.files) ? needs.files : []; if (!files.length) return []; return files.map((need) => { const access = Array.isArray(need.access) ? need.access.join(", ") : ""; const reason = need.reason ? ` - ${need.reason}` : ""; return `${need.bucket}: ${access}${reason}`; }); } function renderPlugins(plugins) { pluginList.innerHTML = ""; if (!plugins.length) { pluginList.innerHTML = '
No plugins installed.
'; return; } for (const plugin of plugins) { const isServerOnly = plugin.runtime === "server"; const card = document.createElement("div"); card.className = isServerOnly ? "plugin-card rounded-xl border border-fuchsia-800/60 bg-fuchsia-950/20 px-4 py-3" : "plugin-card rounded-xl border border-slate-800 bg-slate-950/50 px-4 py-3"; const meta = document.createElement("div"); meta.className = "plugin-card-meta"; const titleRow = document.createElement("div"); titleRow.className = "plugin-card-title flex items-center gap-2"; const title = document.createElement("span"); title.className = "font-semibold"; title.textContent = plugin.name || plugin.id; titleRow.appendChild(title); // Trust badge const badge = getTrustBadge(plugin.signature); const trustBadge = document.createElement("span"); trustBadge.className = `inline-flex items-center gap-1 text-[10px] uppercase tracking-wider px-2 py-0.5 rounded-full border ${badge.color}`; trustBadge.innerHTML = ` ${badge.label}`; trustBadge.title = badge.tooltip; titleRow.appendChild(trustBadge); const runtimeBadge = document.createElement("span"); const isWasm = plugin.runtime === "wasm"; const isV2 = Number(plugin.apiVersion || 1) >= 2; runtimeBadge.className = `inline-flex items-center gap-1 text-[10px] uppercase tracking-wider px-2 py-0.5 rounded-full border ${ isServerOnly ? "text-fuchsia-200 border-fuchsia-700 bg-fuchsia-950/50" : isWasm ? "text-cyan-300 border-cyan-700 bg-cyan-950/40" : isV2 ? "text-emerald-300 border-emerald-700 bg-emerald-950/40" : "text-slate-300 border-slate-700 bg-slate-900/60" }`; runtimeBadge.innerHTML = isServerOnly ? ' Server Extension' : isWasm ? ' WASM 2.0' : isV2 ? ' Plugin 2.0' : ' Native Legacy'; titleRow.appendChild(runtimeBadge); const subtitle = document.createElement("div"); subtitle.className = "plugin-card-subtitle text-sm text-slate-400"; subtitle.textContent = `${plugin.id}${plugin.version ? ` • v${plugin.version}` : ""}`; if (plugin.signature?.fingerprint) { const fpSpan = document.createElement("span"); fpSpan.className = "ml-2 text-xs text-slate-500 font-mono"; fpSpan.textContent = `${plugin.signature.fingerprint.slice(0, 16)}…`; fpSpan.title = `Signer fingerprint: ${plugin.signature.fingerprint}`; subtitle.appendChild(fpSpan); } meta.appendChild(titleRow); meta.appendChild(subtitle); const needLines = describeNeeds(plugin.needs); if (needLines.length) { const needsBox = document.createElement("div"); needsBox.className = "mt-2 text-xs text-slate-300 space-y-1"; needsBox.innerHTML = `
${plugin.needsApproved ? "Needs approved" : isServerOnly ? "Needs approval before enable" : "Needs approval before load"}
${needLines.map((line) => `
${escapeHtml(line)}
`).join("")} `; meta.appendChild(needsBox); } if (isServerOnly) { const serverBox = document.createElement("div"); serverBox.className = "mt-2 flex flex-wrap items-center gap-2 text-xs"; const state = document.createElement("span"); state.className = `inline-flex items-center gap-1 px-2 py-1 rounded-lg border ${ plugin.serverRunning ? "text-emerald-200 border-emerald-700 bg-emerald-950/40" : plugin.enabled ? "text-amber-200 border-amber-700 bg-amber-950/40" : "text-slate-300 border-slate-700 bg-slate-900/60" }`; state.innerHTML = plugin.serverRunning ? ' Worker running' : plugin.enabled ? ' Enabled, worker stopped' : ' Disabled'; serverBox.appendChild(state); if (plugin.build) { const buildBadge = document.createElement("span"); buildBadge.className = "inline-flex items-center gap-1 px-2 py-1 rounded-lg border text-blue-200 border-blue-700 bg-blue-950/40"; buildBadge.innerHTML = ' Build plugin'; serverBox.appendChild(buildBadge); } meta.appendChild(serverBox); } const actions = document.createElement("div"); actions.className = "plugin-card-actions flex items-center gap-2"; const toggle = document.createElement("button"); toggle.className = "inline-flex items-center gap-2 px-3 py-2 rounded-lg border" + (plugin.enabled ? " border-emerald-600 text-emerald-200 bg-emerald-900/40" : " border-slate-600 text-slate-300 bg-slate-800/60"); toggle.innerHTML = isServerOnly ? plugin.enabled ? ' Disable' : ' Enable' : plugin.enabled ? ' Enabled' : ' Disabled'; toggle.addEventListener("click", async () => { const wantEnabled = !plugin.enabled; try { const res = await fetch(`/api/plugins/${plugin.id}/enable`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ enabled: wantEnabled }), }); if (!res.ok) { const data = await res.json().catch(() => null); if (data && data.error === "needs_approval_required") { showNeedsApprovalModal(plugin, data.needs, data.needsHash, async () => { await setPluginEnabled(plugin, wantEnabled, true); await refresh(); }); return; } if (data && data.error === "confirmation_required") { showPluginEnableConfirmModal(plugin, data.signature); return; } setStatus(`Enable failed: ${data?.error || res.statusText}`, true); return; } notifyPluginsChanged(); await refresh(); } catch (err) { setStatus("Enable failed", true); } }); let autoLoadBtn = null; if (!isServerOnly) { autoLoadBtn = document.createElement("button"); const autoLoadDisabled = !plugin.enabled; autoLoadBtn.className = "inline-flex items-center gap-2 px-3 py-2 rounded-lg border" + (autoLoadDisabled ? " border-slate-700 text-slate-500 bg-slate-900/40 cursor-not-allowed opacity-50" : plugin.autoLoad ? " border-amber-600 text-amber-200 bg-amber-900/40" : " border-slate-600 text-slate-300 bg-slate-800/60"); autoLoadBtn.innerHTML = plugin.autoLoad ? ' Auto-load' : ' Auto-load off'; autoLoadBtn.title = autoLoadDisabled ? "Plugin must be enabled before auto-load can be turned on" : plugin.autoLoad ? "Plugin will auto-load on all new client connections. Click to disable." : "Click to auto-load this plugin on all new client connections."; if (autoLoadDisabled) { autoLoadBtn.disabled = true; } else { autoLoadBtn.addEventListener("click", async () => { const res = await fetch(`/api/plugins/${plugin.id}/autoload`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ autoLoad: !plugin.autoLoad, autoStartEvents: plugin.autoStartEvents || [], }), }); if (!res.ok) { const data = await res.json().catch(() => null); if (data && data.error === "needs_approval_required") { showNeedsApprovalModal(plugin, data.needs, data.needsHash, async () => { await fetch(`/api/plugins/${plugin.id}/autoload`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ autoLoad: !plugin.autoLoad, autoStartEvents: plugin.autoStartEvents || [], }), }); await refresh(); }); return; } setStatus(`Auto-load toggle failed: ${data?.error || res.statusText}`, true); return; } notifyPluginsChanged(); await refresh(); }); } } if (needLines.length && !plugin.needsApproved) { const approveBtn = document.createElement("button"); approveBtn.className = "inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-amber-900/40 border border-amber-700/60 hover:bg-amber-800/60 text-amber-100"; approveBtn.innerHTML = ' Approve needs'; approveBtn.addEventListener("click", () => showNeedsApprovalModal(plugin, plugin.needs, plugin.needsHash, refresh)); actions.appendChild(approveBtn); } const removeBtn = document.createElement("button"); removeBtn.className = "inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-red-900/40 border border-red-700/60 hover:bg-red-800/60 text-red-100"; removeBtn.innerHTML = ' Remove'; removeBtn.addEventListener("click", async () => { if (!confirm(`Remove plugin ${plugin.name || plugin.id}?`)) return; const res = await fetch(`/api/plugins/${plugin.id}`, { method: "DELETE" }); if (!res.ok) { const text = await res.text(); setStatus(`Remove failed: ${text}`, true); return; } setStatus("Plugin removed."); notifyPluginsChanged(); await refresh(); }); actions.appendChild(toggle); if (autoLoadBtn) actions.appendChild(autoLoadBtn); actions.appendChild(removeBtn); card.appendChild(meta); card.appendChild(actions); if (plugin.lastError) { const errorRow = document.createElement("div"); errorRow.className = "plugin-card-error mt-2 text-xs text-red-300"; errorRow.textContent = `Last error: ${plugin.lastError}`; card.appendChild(errorRow); } pluginList.appendChild(card); } } function escapeHtml(value) { return String(value) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } async function setPluginEnabled(plugin, enabled, confirmed = false) { return fetch(`/api/plugins/${plugin.id}/enable`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ enabled, confirmed }), }); } function showNeedsApprovalModal(plugin, needs, needsHash, afterApprove) { document.getElementById("plugin-needs-approval-modal")?.remove(); const needLines = describeNeeds(needs); const overlay = document.createElement("div"); overlay.id = "plugin-needs-approval-modal"; overlay.className = "plugin-modal fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"; overlay.innerHTML = `

Approve Plugin Needs

${escapeHtml(plugin.name || plugin.id)} is asking for filesystem bridges before it can ${plugin.runtime === "server" ? "be enabled" : "be sent to clients"}.

${needLines.length ? needLines.map((line) => `
${escapeHtml(line)}
`).join("") : '
No filesystem needs declared.
'}
${needsHash ? `

Needs hash: ${escapeHtml(needsHash.slice(0, 16))}...

` : ""}
`; document.body.appendChild(overlay); document.getElementById("plugin-needs-cancel")?.addEventListener("click", () => overlay.remove()); overlay.addEventListener("click", (e) => { if (e.target === overlay) overlay.remove(); }); document.getElementById("plugin-needs-ok")?.addEventListener("click", async () => { const okBtn = document.getElementById("plugin-needs-ok"); okBtn.disabled = true; okBtn.textContent = "Approving..."; const res = await fetch(`/api/plugins/${plugin.id}/needs/approve`, { method: "POST" }); if (!res.ok) { const text = await res.text(); setStatus(`Needs approval failed: ${text}`, true); overlay.remove(); return; } overlay.remove(); setStatus("Plugin needs approved."); notifyPluginsChanged(); if (typeof afterApprove === "function") await afterApprove(); }); } function showPluginEnableConfirmModal(plugin, sigInfo) { document.getElementById("plugin-enable-confirm-modal")?.remove(); const sig = sigInfo || {}; let statusText = "This plugin is unsigned."; let statusColor = "text-orange-400"; if (sig.signed && sig.valid && !sig.trusted) { statusText = "This plugin is signed but the signing key is not trusted."; statusColor = "text-yellow-400"; } else if (sig.signed && !sig.valid) { statusText = "This plugin has an invalid signature and may have been tampered with."; statusColor = "text-red-400"; } const overlay = document.createElement("div"); overlay.id = "plugin-enable-confirm-modal"; overlay.className = "plugin-modal fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"; overlay.innerHTML = `

Enable Unverified Plugin

${statusText}

Plugin: ${plugin.name || plugin.id}

${sig.fingerprint ? `

Signer: ${sig.fingerprint}

` : '

No signature present.

'}

Type confirm below to enable this plugin:

`; document.body.appendChild(overlay); const input = document.getElementById("plugin-enable-confirm-input"); const okBtn = document.getElementById("plugin-enable-confirm-ok"); const cancelBtn = document.getElementById("plugin-enable-confirm-cancel"); input.addEventListener("input", () => { const match = input.value.trim().toLowerCase() === "confirm"; okBtn.disabled = !match; okBtn.classList.toggle("opacity-50", !match); okBtn.classList.toggle("cursor-not-allowed", !match); okBtn.classList.toggle("hover:bg-emerald-800/60", match); }); cancelBtn.addEventListener("click", () => overlay.remove()); overlay.addEventListener("click", (e) => { if (e.target === overlay) overlay.remove(); }); okBtn.addEventListener("click", async () => { okBtn.disabled = true; okBtn.textContent = "Enabling..."; let enabled = false; try { const res = await fetch(`/api/plugins/${plugin.id}/enable`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ enabled: true, confirmed: true }), }); if (!res.ok) { const data = await res.json().catch(() => null); if (data && data.error === "needs_approval_required") { overlay.remove(); showNeedsApprovalModal(plugin, data.needs, data.needsHash, async () => { await setPluginEnabled(plugin, true, true); await refresh(); }); return; } setStatus(`Enable failed: ${data?.error || res.statusText}`, true); } else { enabled = true; } } catch { setStatus("Enable failed", true); } overlay.remove(); if (enabled) notifyPluginsChanged(); await refresh(); }); input.focus(); } async function refresh() { const plugins = await fetchPlugins(); renderPlugins(plugins); } async function uploadFile(file) { if (!file) return; setStatus(`Uploading ${file.name}...`); const form = new FormData(); form.append("file", file); const res = await fetch("/api/plugins/upload", { method: "POST", body: form }); if (!res.ok) { const text = await res.text(); setStatus(`Upload failed: ${text}`, true); return; } setStatus("Upload complete."); notifyPluginsChanged(); await refresh(); } if (dropzone) { dropzone.addEventListener("dragover", (e) => { e.preventDefault(); dropzone.classList.add("border-emerald-500", "text-emerald-300"); }); dropzone.addEventListener("dragleave", () => { dropzone.classList.remove("border-emerald-500", "text-emerald-300"); }); dropzone.addEventListener("drop", (e) => { e.preventDefault(); dropzone.classList.remove("border-emerald-500", "text-emerald-300"); const file = e.dataTransfer?.files?.[0]; if (file) uploadFile(file); }); } fileInput?.addEventListener("change", (e) => { const file = e.target.files?.[0]; if (file) uploadFile(file); }); refreshBtn?.addEventListener("click", refresh); const trustedKeysSection = document.getElementById("trusted-keys-section"); const trustedKeysList = document.getElementById("trusted-keys-list"); const addKeyBtn = document.getElementById("add-trusted-key-btn"); const newKeyInput = document.getElementById("new-trusted-key-input"); let _builtinKeys = []; async function fetchTrustedKeys() { try { const res = await fetch("/api/plugins/trusted-keys"); if (!res.ok) { if (res.status === 403) { if (trustedKeysSection) trustedKeysSection.classList.add("hidden"); return []; } return []; } const data = await res.json(); if (trustedKeysSection) trustedKeysSection.classList.remove("hidden"); _builtinKeys = Array.isArray(data.builtinKeys) ? data.builtinKeys : []; return Array.isArray(data.trustedKeys) ? data.trustedKeys : []; } catch { return []; } } function renderTrustedKeys(keys) { if (!trustedKeysList) return; trustedKeysList.innerHTML = ""; if (!keys.length) { trustedKeysList.innerHTML = '
No trusted keys configured. All plugins will require confirmation to load.
'; return; } for (const key of keys) { const isBuiltin = _builtinKeys.includes(key); const row = document.createElement("div"); row.className = "trusted-key-row flex items-center gap-2 px-3 py-2 rounded-lg border border-slate-800 bg-slate-950/50"; const fp = document.createElement("span"); fp.className = "font-mono text-sm text-slate-300 flex-1 truncate"; fp.textContent = key; fp.title = key; row.appendChild(fp); if (isBuiltin) { const badge = document.createElement("span"); badge.className = "text-xs text-emerald-400 px-2 py-0.5 rounded bg-emerald-900/30 whitespace-nowrap"; badge.textContent = "built-in"; badge.title = "This key is hardcoded and always trusted"; row.appendChild(badge); } else { const removeBtn = document.createElement("button"); removeBtn.className = "text-red-400 hover:text-red-300 text-sm px-2 py-1"; removeBtn.innerHTML = ''; removeBtn.title = "Remove trusted key"; removeBtn.addEventListener("click", async () => { const res = await fetch(`/api/plugins/trusted-keys/${key}`, { method: "DELETE" }); if (res.ok) { await refreshTrustedKeys(); await refresh(); } }); row.appendChild(removeBtn); } trustedKeysList.appendChild(row); } } async function refreshTrustedKeys() { const keys = await fetchTrustedKeys(); renderTrustedKeys(keys); } addKeyBtn?.addEventListener("click", async () => { const fp = newKeyInput?.value?.trim().toLowerCase(); if (!fp || !/^[a-f0-9]{64}$/.test(fp)) { setStatus("Invalid fingerprint — must be a 64-character hex string (SHA-256)", true); return; } const res = await fetch("/api/plugins/trusted-keys", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ fingerprint: fp }), }); if (res.ok) { if (newKeyInput) newKeyInput.value = ""; setStatus("Trusted key added."); await refreshTrustedKeys(); await refresh(); } else { const data = await res.json().catch(() => ({})); setStatus(`Failed to add key: ${data.error || "unknown error"}`, true); } }); checkAuth(); refresh(); refreshTrustedKeys();