diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 5fbd526..ffedc15 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -22,7 +22,7 @@ from pydantic import BaseModel from .config import load_config from . import systemctl as sysctl -# ── Constants ──────────────────────────────��───────────────────── +# ── Constants ────────────────────────────────────────────────────── FLAKE_LOCK_PATH = "/etc/nixos/flake.lock" FLAKE_INPUT_NAME = "Sovran_Systems" @@ -146,6 +146,16 @@ FEATURE_REGISTRY = [ }, ] +# Map feature IDs to their systemd units in config.json +FEATURE_SERVICE_MAP = { + "rdp": "gnome-remote-desktop.service", + "haven": "haven-relay.service", + "element-calling": "livekit.service", + "mempool": "mempool-frontend.service", + "bip110": None, + "bitcoin-core": None, +} + ROLE_LABELS = { "server_plus_desktop": "Server + Desktop", "desktop": "Desktop Only", @@ -191,7 +201,7 @@ def _file_hash(filename: str) -> str: _APP_JS_HASH = _file_hash("app.js") _STYLE_CSS_HASH = _file_hash("style.css") -# ── Update check helpers ──────────────────��────────────────────── +# ── Update check helpers ────────────────────────────────────────── def _get_locked_info(): try: @@ -463,6 +473,21 @@ def _write_hub_overrides(features: dict, nostr_npub: str | None) -> None: f.write(content) +# ── Feature status helpers ───────────────────────────────────────── + +def _is_feature_enabled_in_config(feature_id: str) -> bool | None: + """Check if a feature's service appears as enabled in the running config.json. + Returns True/False if found, None if the feature has no mapped service.""" + unit = FEATURE_SERVICE_MAP.get(feature_id) + if unit is None: + return None # bip110, bitcoin-core — can't determine from config + cfg = load_config() + for svc in cfg.get("services", []): + if svc.get("unit") == unit: + return svc.get("enabled", False) + return None + + # ── Tech Support helpers ────────────────────────────────────────── def _is_support_active() -> bool: @@ -582,7 +607,6 @@ async def api_config(): "role": role, "role_label": ROLE_LABELS.get(role, role), "category_order": CATEGORY_ORDER, - "feature_manager": cfg.get("feature_manager", False), } @@ -786,7 +810,18 @@ async def api_features(): features = [] for feat in FEATURE_REGISTRY: feat_id = feat["id"] - enabled = overrides.get(feat_id, False) + + # Determine enabled state: + # 1. Check hub-overrides.nix first (explicit hub toggle) + # 2. Fall back to config.json services (features enabled in custom.nix) + if feat_id in overrides: + enabled = overrides[feat_id] + else: + config_state = _is_feature_enabled_in_config(feat_id) + if config_state is not None: + enabled = config_state + else: + enabled = False domain_name = feat.get("domain_name") domain_configured = True @@ -1015,4 +1050,4 @@ async def _startup_save_ip(): """Write internal IP to file on server start so credentials work immediately.""" loop = asyncio.get_event_loop() ip = await loop.run_in_executor(None, _get_internal_ip) - _save_internal_ip(ip) + _save_internal_ip(ip) \ No newline at end of file diff --git a/app/sovran_systemsos_web/static/app.js b/app/sovran_systemsos_web/static/app.js index 39f875f..6db8a1e 100644 --- a/app/sovran_systemsos_web/static/app.js +++ b/app/sovran_systemsos_web/static/app.js @@ -1,14 +1,14 @@ /* Sovran_SystemsOS Hub — Vanilla JS Frontend - v7 — Status-only dashboard + Tech Support + Feature Manager */ + v8 — Status-only dashboard + Tech Support + Feature Manager */ "use strict"; -const POLL_INTERVAL_SERVICES = 5000; -const POLL_INTERVAL_UPDATES = 1800000; -const UPDATE_POLL_INTERVAL = 2000; -const REBOOT_CHECK_INTERVAL = 5000; -const SUPPORT_TIMER_INTERVAL = 1000; +var POLL_INTERVAL_SERVICES = 5000; +var POLL_INTERVAL_UPDATES = 1800000; +var UPDATE_POLL_INTERVAL = 2000; +var REBOOT_CHECK_INTERVAL = 5000; +var SUPPORT_TIMER_INTERVAL = 1000; -const CATEGORY_ORDER = [ +var CATEGORY_ORDER = [ "infrastructure", "bitcoin-base", "bitcoin-apps", @@ -19,97 +19,104 @@ const CATEGORY_ORDER = [ "feature-manager", ]; -const FEATURE_SUBCATEGORY_LABELS = { +var FEATURE_SUBCATEGORY_LABELS = { "infrastructure": "🔧 Infrastructure", "bitcoin": "₿ Bitcoin", "communication": "💬 Communication", "nostr": "📡 Nostr", }; -const FEATURE_SUBCATEGORY_ORDER = ["infrastructure", "bitcoin", "communication", "nostr"]; +var FEATURE_SUBCATEGORY_ORDER = ["infrastructure", "bitcoin", "communication", "nostr"]; -const STATUS_LOADING_STATES = new Set([ +var FEATURE_UNIT_MAP = { + "rdp": "gnome-remote-desktop.service", + "haven": "haven-relay.service", + "element-calling": "livekit.service", + "mempool": "mempool-frontend.service", +}; + +var STATUS_LOADING_STATES = new Set([ "reloading", "activating", "deactivating", "maintenance", ]); // ── State ───────────────────────────────────────────────────────── -let _servicesCache = []; -let _categoryLabels = {}; -let _updateLog = ""; -let _updatePollTimer = null; -let _updateLogOffset = 0; -let _serverWasDown = false; -let _updateFinished = false; -let _supportTimerInt = null; -let _supportEnabledAt = null; -let _cachedExternalIp = null; +var _servicesCache = []; +var _categoryLabels = {}; +var _updateLog = ""; +var _updatePollTimer = null; +var _updateLogOffset = 0; +var _serverWasDown = false; +var _updateFinished = false; +var _supportTimerInt = null; +var _supportEnabledAt = null; +var _cachedExternalIp = null; // Feature Manager state -let _featuresData = null; -let _rebuildLog = ""; -let _rebuildLogOffset = 0; -let _rebuildPollTimer = null; -let _rebuildFinished = false; -let _rebuildServerDown = false; -let _pendingToggle = null; // {feature, extra} waiting for domain/confirm +var _featuresData = null; +var _rebuildLog = ""; +var _rebuildLogOffset = 0; +var _rebuildPollTimer = null; +var _rebuildFinished = false; +var _rebuildServerDown = false; +var _pendingToggle = null; // ── DOM refs ────────────────────────────────────────────────────── -const $tilesArea = document.getElementById("tiles-area"); -const $updateBtn = document.getElementById("btn-update"); -const $updateBadge = document.getElementById("update-badge"); -const $refreshBtn = document.getElementById("btn-refresh"); -const $internalIp = document.getElementById("ip-internal"); -const $externalIp = document.getElementById("ip-external"); +var $tilesArea = document.getElementById("tiles-area"); +var $updateBtn = document.getElementById("btn-update"); +var $updateBadge = document.getElementById("update-badge"); +var $refreshBtn = document.getElementById("btn-refresh"); +var $internalIp = document.getElementById("ip-internal"); +var $externalIp = document.getElementById("ip-external"); -const $modal = document.getElementById("update-modal"); -const $modalSpinner = document.getElementById("modal-spinner"); -const $modalStatus = document.getElementById("modal-status"); -const $modalLog = document.getElementById("modal-log"); -const $btnReboot = document.getElementById("btn-reboot"); -const $btnSave = document.getElementById("btn-save-report"); -const $btnCloseModal = document.getElementById("btn-close-modal"); +var $modal = document.getElementById("update-modal"); +var $modalSpinner = document.getElementById("modal-spinner"); +var $modalStatus = document.getElementById("modal-status"); +var $modalLog = document.getElementById("modal-log"); +var $btnReboot = document.getElementById("btn-reboot"); +var $btnSave = document.getElementById("btn-save-report"); +var $btnCloseModal = document.getElementById("btn-close-modal"); -const $rebootOverlay = document.getElementById("reboot-overlay"); +var $rebootOverlay = document.getElementById("reboot-overlay"); -const $credsModal = document.getElementById("creds-modal"); -const $credsTitle = document.getElementById("creds-modal-title"); -const $credsBody = document.getElementById("creds-body"); -const $credsCloseBtn = document.getElementById("creds-close-btn"); +var $credsModal = document.getElementById("creds-modal"); +var $credsTitle = document.getElementById("creds-modal-title"); +var $credsBody = document.getElementById("creds-body"); +var $credsCloseBtn = document.getElementById("creds-close-btn"); -const $supportModal = document.getElementById("support-modal"); -const $supportBody = document.getElementById("support-body"); -const $supportCloseBtn = document.getElementById("support-close-btn"); +var $supportModal = document.getElementById("support-modal"); +var $supportBody = document.getElementById("support-body"); +var $supportCloseBtn = document.getElementById("support-close-btn"); // Feature Manager — rebuild modal -const $rebuildModal = document.getElementById("rebuild-modal"); -const $rebuildSpinner = document.getElementById("rebuild-spinner"); -const $rebuildStatus = document.getElementById("rebuild-status"); -const $rebuildLog = document.getElementById("rebuild-log"); -const $rebuildReboot = document.getElementById("rebuild-reboot-btn"); -const $rebuildSave = document.getElementById("rebuild-save-report"); -const $rebuildClose = document.getElementById("rebuild-close-btn"); +var $rebuildModal = document.getElementById("rebuild-modal"); +var $rebuildSpinner = document.getElementById("rebuild-spinner"); +var $rebuildStatus = document.getElementById("rebuild-status"); +var $rebuildLogEl = document.getElementById("rebuild-log"); +var $rebuildReboot = document.getElementById("rebuild-reboot-btn"); +var $rebuildSave = document.getElementById("rebuild-save-report"); +var $rebuildClose = document.getElementById("rebuild-close-btn"); // Feature Manager — domain setup modal -const $domainSetupModal = document.getElementById("domain-setup-modal"); -const $domainSetupTitle = document.getElementById("domain-setup-title"); -const $domainSetupBody = document.getElementById("domain-setup-body"); -const $domainSetupClose = document.getElementById("domain-setup-close-btn"); +var $domainSetupModal = document.getElementById("domain-setup-modal"); +var $domainSetupTitle = document.getElementById("domain-setup-title"); +var $domainSetupBody = document.getElementById("domain-setup-body"); +var $domainSetupClose = document.getElementById("domain-setup-close-btn"); // Feature Manager — SSL email modal -const $sslEmailModal = document.getElementById("ssl-email-modal"); -const $sslEmailInput = document.getElementById("ssl-email-input"); -const $sslEmailSave = document.getElementById("ssl-email-save-btn"); -const $sslEmailCancel = document.getElementById("ssl-email-cancel-btn"); -const $sslEmailClose = document.getElementById("ssl-email-close-btn"); +var $sslEmailModal = document.getElementById("ssl-email-modal"); +var $sslEmailInput = document.getElementById("ssl-email-input"); +var $sslEmailSave = document.getElementById("ssl-email-save-btn"); +var $sslEmailCancel = document.getElementById("ssl-email-cancel-btn"); +var $sslEmailClose = document.getElementById("ssl-email-close-btn"); // Feature Manager — confirm modal -const $featureConfirmModal = document.getElementById("feature-confirm-modal"); -const $featureConfirmMsg = document.getElementById("feature-confirm-message"); -const $featureConfirmOk = document.getElementById("feature-confirm-ok-btn"); -const $featureConfirmCancel = document.getElementById("feature-confirm-cancel-btn"); -const $featureConfirmClose = document.getElementById("feature-confirm-close-btn"); +var $featureConfirmModal = document.getElementById("feature-confirm-modal"); +var $featureConfirmMsg = document.getElementById("feature-confirm-message"); +var $featureConfirmOk = document.getElementById("feature-confirm-ok-btn"); +var $featureConfirmCancel = document.getElementById("feature-confirm-cancel-btn"); +var $featureConfirmClose = document.getElementById("feature-confirm-close-btn"); // ── Helpers ─────────────────────────────────────────────────────── @@ -140,9 +147,9 @@ function linkify(str) { } function formatDuration(seconds) { - const h = Math.floor(seconds / 3600); - const m = Math.floor((seconds % 3600) / 60); - const s = Math.floor(seconds % 60); + var h = Math.floor(seconds / 3600); + var m = Math.floor((seconds % 3600) / 60); + var s = Math.floor(seconds % 60); if (h > 0) return h + "h " + m + "m " + s + "s"; if (m > 0) return m + "m " + s + "s"; return s + "s"; @@ -151,7 +158,7 @@ function formatDuration(seconds) { // ── Fetch wrappers ──────────────────────────────────────────────── async function apiFetch(path, options) { - const res = await fetch(path, options || {}); + var res = await fetch(path, options || {}); if (!res.ok) throw new Error(res.status + " " + res.statusText); return res.json(); } @@ -280,7 +287,7 @@ async function checkUpdates() { } catch (_) {} } -// ── Credentials info modal ──────────────────────────────────────── +// ── Credentials info modal ──────────��───────────────────────────── async function openCredsModal(unit, name) { if (!$credsModal) return; @@ -368,641 +375,4 @@ async function enableSupport() { if (btn) { btn.disabled = true; btn.textContent = "Enabling…"; } try { await apiFetch("/api/support/enable", { method: "POST" }); - var status = await apiFetch("/api/support/status"); - _supportEnabledAt = status.enabled_at; - renderSupportActive(); - } catch (err) { - if (btn) { btn.disabled = false; btn.textContent = "Enable Support Access"; } - alert("Failed to enable support access. Please try again."); - } -} - -async function disableSupport() { - var btn = document.getElementById("btn-support-disable"); - if (btn) { btn.disabled = true; btn.textContent = "Removing key…"; } - try { - var result = await apiFetch("/api/support/disable", { method: "POST" }); - renderSupportRemoved(result.verified); - } catch (err) { - if (btn) { btn.disabled = false; btn.textContent = "End Support Session"; } - alert("Failed to disable support access. Please try again."); - } -} - -function startSupportTimer() { - stopSupportTimer(); - updateSupportTimer(); - _supportTimerInt = setInterval(updateSupportTimer, SUPPORT_TIMER_INTERVAL); -} - -function stopSupportTimer() { - if (_supportTimerInt) { clearInterval(_supportTimerInt); _supportTimerInt = null; } -} - -function updateSupportTimer() { - var el = document.getElementById("support-timer"); - if (!el || !_supportEnabledAt) return; - var elapsed = (Date.now() / 1000) - _supportEnabledAt; - el.textContent = formatDuration(Math.max(0, elapsed)); -} - -function closeSupportModal() { - if ($supportModal) $supportModal.classList.remove("open"); - stopSupportTimer(); -} - -// ── Update modal ────────────────────────────────────────────────── - -function openUpdateModal() { - if (!$modal) return; - _updateLog = ""; - _updateLogOffset = 0; - _serverWasDown = false; - _updateFinished = false; - if ($modalLog) $modalLog.textContent = ""; - if ($modalStatus) $modalStatus.textContent = "Starting update…"; - if ($modalSpinner) $modalSpinner.classList.add("spinning"); - if ($btnReboot) $btnReboot.style.display = "none"; - if ($btnSave) $btnSave.style.display = "none"; - if ($btnCloseModal) $btnCloseModal.disabled = true; - $modal.classList.add("open"); - startUpdate(); -} - -function closeUpdateModal() { - if (!$modal) return; - $modal.classList.remove("open"); - stopUpdatePoll(); -} - -function appendLog(text) { - if (!text) return; - _updateLog += text; - if ($modalLog) { $modalLog.textContent += text; $modalLog.scrollTop = $modalLog.scrollHeight; } -} - -function startUpdate() { - fetch("/api/updates/run", { method: "POST" }) - .then(function(response) { - if (!response.ok) return response.text().then(function(t) { throw new Error(t); }); - return response.json(); - }) - .then(function(data) { - if (data.status === "already_running") appendLog("[Update already in progress, attaching…]\n\n"); - if ($modalStatus) $modalStatus.textContent = "Updating…"; - startUpdatePoll(); - }) - .catch(function(err) { - appendLog("[Error: failed to start update — " + err + "]\n"); - onUpdateDone(false); - }); -} - -function startUpdatePoll() { - pollUpdateStatus(); - _updatePollTimer = setInterval(pollUpdateStatus, UPDATE_POLL_INTERVAL); -} - -function stopUpdatePoll() { - if (_updatePollTimer) { clearInterval(_updatePollTimer); _updatePollTimer = null; } -} - -async function pollUpdateStatus() { - if (_updateFinished) return; - try { - var data = await apiFetch("/api/updates/status?offset=" + _updateLogOffset); - if (_serverWasDown) { _serverWasDown = false; appendLog("[Server reconnected]\n"); if ($modalStatus) $modalStatus.textContent = "Updating…"; } - if (data.log) appendLog(data.log); - _updateLogOffset = data.offset; - if (data.running) return; - _updateFinished = true; - stopUpdatePoll(); - if (data.result === "success") onUpdateDone(true); - else onUpdateDone(false); - } catch (err) { - if (!_serverWasDown) { _serverWasDown = true; appendLog("\n[Server restarting — waiting for it to come back…]\n"); if ($modalStatus) $modalStatus.textContent = "Server restarting…"; } - } -} - -function onUpdateDone(success) { - if ($modalSpinner) $modalSpinner.classList.remove("spinning"); - if ($btnCloseModal) $btnCloseModal.disabled = false; - if (success) { - if ($modalStatus) $modalStatus.textContent = "✓ Update complete"; - if ($btnReboot) $btnReboot.style.display = "inline-flex"; - } else { - if ($modalStatus) $modalStatus.textContent = "✗ Update failed"; - if ($btnSave) $btnSave.style.display = "inline-flex"; - if ($btnReboot) $btnReboot.style.display = "inline-flex"; - } -} - -function saveErrorReport() { - var blob = new Blob([_updateLog], { type: "text/plain" }); - var url = URL.createObjectURL(blob); - var a = document.createElement("a"); - a.href = url; - a.download = "sovran-update-error-" + new Date().toISOString().split(".")[0].replace(/:/g, "-") + ".txt"; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); -} - -// ── Reboot ──────────────────────────────────────────────────────── - -function doReboot() { - if ($modal) $modal.classList.remove("open"); - stopUpdatePoll(); - if ($rebootOverlay) $rebootOverlay.classList.add("visible"); - fetch("/api/reboot", { method: "POST" }).catch(function() {}); - setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL); -} - -function waitForServerReboot() { - fetch("/api/config", { cache: "no-store" }) - .then(function(res) { - if (res.ok) window.location.reload(); - else setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL); - }) - .catch(function() { setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL); }); -} - -// ── Rebuild modal ───────────────────────────────────────────────── - -function openRebuildModal() { - if (!$rebuildModal) return; - _rebuildLog = ""; - _rebuildLogOffset = 0; - _rebuildServerDown = false; - _rebuildFinished = false; - if ($rebuildLog) $rebuildLog.textContent = ""; - if ($rebuildStatus) $rebuildStatus.textContent = "Rebuilding…"; - if ($rebuildSpinner) $rebuildSpinner.classList.add("spinning"); - if ($rebuildReboot) $rebuildReboot.style.display = "none"; - if ($rebuildSave) $rebuildSave.style.display = "none"; - if ($rebuildClose) $rebuildClose.disabled = true; - $rebuildModal.classList.add("open"); - startRebuildPoll(); -} - -function closeRebuildModal() { - if ($rebuildModal) $rebuildModal.classList.remove("open"); - stopRebuildPoll(); -} - -function appendRebuildLog(text) { - if (!text) return; - _rebuildLog += text; - if ($rebuildLog) { $rebuildLog.textContent += text; $rebuildLog.scrollTop = $rebuildLog.scrollHeight; } -} - -function startRebuildPoll() { - pollRebuildStatus(); - _rebuildPollTimer = setInterval(pollRebuildStatus, UPDATE_POLL_INTERVAL); -} - -function stopRebuildPoll() { - if (_rebuildPollTimer) { clearInterval(_rebuildPollTimer); _rebuildPollTimer = null; } -} - -async function pollRebuildStatus() { - if (_rebuildFinished) return; - try { - var data = await apiFetch("/api/rebuild/status?offset=" + _rebuildLogOffset); - if (_rebuildServerDown) { _rebuildServerDown = false; appendRebuildLog("[Server reconnected]\n"); if ($rebuildStatus) $rebuildStatus.textContent = "Rebuilding…"; } - if (data.log) appendRebuildLog(data.log); - _rebuildLogOffset = data.offset; - if (data.running) return; - _rebuildFinished = true; - stopRebuildPoll(); - onRebuildDone(data.result === "success"); - } catch (err) { - if (!_rebuildServerDown) { _rebuildServerDown = true; appendRebuildLog("\n[Server restarting — waiting for it to come back…]\n"); if ($rebuildStatus) $rebuildStatus.textContent = "Server restarting…"; } - } -} - -function onRebuildDone(success) { - if ($rebuildSpinner) $rebuildSpinner.classList.remove("spinning"); - if ($rebuildClose) $rebuildClose.disabled = false; - if (success) { - if ($rebuildStatus) $rebuildStatus.textContent = "✓ Rebuild complete"; - if ($rebuildReboot) $rebuildReboot.style.display = "inline-flex"; - // Refresh feature states - loadFeatureManager(); - } else { - if ($rebuildStatus) $rebuildStatus.textContent = "✗ Rebuild failed"; - if ($rebuildSave) $rebuildSave.style.display = "inline-flex"; - if ($rebuildReboot) $rebuildReboot.style.display = "inline-flex"; - } -} - -function saveRebuildErrorReport() { - var blob = new Blob([_rebuildLog], { type: "text/plain" }); - var url = URL.createObjectURL(blob); - var a = document.createElement("a"); - a.href = url; - a.download = "sovran-rebuild-error-" + new Date().toISOString().split(".")[0].replace(/:/g, "-") + ".txt"; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); -} - -// ── Feature confirm modal ───────────────────────────────────────── - -function openFeatureConfirm(message, onConfirm) { - if (!$featureConfirmModal) return; - if ($featureConfirmMsg) $featureConfirmMsg.textContent = message; - $featureConfirmModal.classList.add("open"); - // Replace ok handler - var newOk = $featureConfirmOk.cloneNode(true); - $featureConfirmOk.parentNode.replaceChild(newOk, $featureConfirmOk); - newOk.addEventListener("click", function() { - closeFeatureConfirm(); - onConfirm(); - }); -} - -function closeFeatureConfirm() { - if ($featureConfirmModal) $featureConfirmModal.classList.remove("open"); -} - -// ── SSL Email modal ─────────────────────────────────────────────── - -function openSslEmailModal(onSaved) { - if (!$sslEmailModal) return; - if ($sslEmailInput) $sslEmailInput.value = ""; - $sslEmailModal.classList.add("open"); - // Replace save handler - var newSave = $sslEmailSave.cloneNode(true); - $sslEmailSave.parentNode.replaceChild(newSave, $sslEmailSave); - newSave.addEventListener("click", async function() { - var email = $sslEmailInput ? $sslEmailInput.value.trim() : ""; - if (!email) { alert("Please enter an email address."); return; } - newSave.disabled = true; - newSave.textContent = "Saving…"; - try { - await apiFetch("/api/domains/set-email", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email: email }), - }); - closeSslEmailModal(); - onSaved(); - } catch (err) { - newSave.disabled = false; - newSave.textContent = "Save"; - alert("Failed to save email. Please try again."); - } - }); -} - -function closeSslEmailModal() { - if ($sslEmailModal) $sslEmailModal.classList.remove("open"); -} - -// ── Domain Setup modal ──────────────────────────────────────────── - -function openDomainSetupModal(feat, onSaved) { - if (!$domainSetupModal) return; - if ($domainSetupTitle) $domainSetupTitle.textContent = "🌐 Domain Setup — " + feat.name; - - var npubField = ""; - if (feat.id === "haven") { - var currentNpub = ""; - if (feat.extra_fields && feat.extra_fields.length > 0) { - for (var i = 0; i < feat.extra_fields.length; i++) { - if (feat.extra_fields[i].id === "nostr_npub") { - currentNpub = feat.extra_fields[i].current_value || ""; - break; - } - } - } - npubField = '
'; - } - - $domainSetupBody.innerHTML = - 'Before continuing, you need:
ℹ Paste the curl URL from your Njal.la dashboard\'s Dynamic record