Purchase a new domain on Njal.la, or create a subdomain from a domain you already own. Tip: Subdomains are free to create — you only need to purchase one domain, and you can add as many subdomains as you need at no extra cost.
' +
'
In the Njal.la web interface, create a Dynamic record pointing to this machine\'s external IP address: ' +
'' + escHtml(externalIp) + '
' +
'
Njal.la will give you a curl command like: ' +
'curl "https://njal.la/update/?h=sub.domain.com&k=abc123&auto"
' +
'
Enter the subdomain and paste that curl command below
' +
'' +
'
' +
'' +
'
ℹ Paste the full curl command from your Njal.la dashboard\'s Dynamic record
' +
npubField +
'';
document.getElementById("domain-setup-cancel-btn").addEventListener("click", closeDomainSetupModal);
document.getElementById("domain-setup-save-btn").addEventListener("click", async function() {
var subdomain = (document.getElementById("domain-subdomain-input") || {}).value || "";
var ddnsUrl = (document.getElementById("domain-ddns-input") || {}).value || "";
var npub = document.getElementById("domain-npub-input") ? (document.getElementById("domain-npub-input").value || "") : "";
subdomain = subdomain.trim();
ddnsUrl = ddnsUrl.trim();
npub = npub.trim();
if (!subdomain) { alert("Please enter a subdomain."); return; }
if (feat.id === "haven" && !npub) { alert("Please enter your Nostr public key."); return; }
var saveBtn = document.getElementById("domain-setup-save-btn");
saveBtn.disabled = true;
saveBtn.textContent = "Saving…";
try {
await apiFetch("/api/domains/set", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
domain_name: feat.domain_name,
domain: subdomain,
ddns_url: ddnsUrl,
}),
});
closeDomainSetupModal();
onSaved(npub);
} catch (err) {
saveBtn.disabled = false;
saveBtn.textContent = "Save & Enable";
alert("Failed to save domain. Please try again.");
}
});
$domainSetupModal.classList.add("open");
}
function closeDomainSetupModal() {
if ($domainSetupModal) $domainSetupModal.classList.remove("open");
}
// ── Port Requirements modal ───────────────────────────────────────
function openPortRequirementsModal(featureName, ports, onContinue) {
if (!$portReqModal || !$portReqBody) return;
var continueBtn = onContinue
? ''
: '';
// Show loading state while fetching port status
$portReqBody.innerHTML =
'
Checking port status for ' + escHtml(featureName) + '…
' +
'
Detecting which ports are open on this machine…
';
$portReqModal.classList.add("open");
// Fetch live port status from local system commands (no external calls)
fetch("/api/ports/status", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ports: ports }),
})
.then(function(r) { return r.json(); })
.then(function(data) {
var internalIp = (data.internal_ip && data.internal_ip !== "unavailable")
? data.internal_ip : null;
var portStatuses = {};
(data.ports || []).forEach(function(p) {
portStatuses[p.port + "/" + p.protocol] = p.status;
});
var rows = ports.map(function(p) {
var key = p.port + "/" + p.protocol;
var status = portStatuses[key] || "unknown";
var statusHtml;
if (status === "listening") {
statusHtml = '🟢 Listening';
} else if (status === "firewall_open") {
statusHtml = '🟡 Open (idle)';
} else if (status === "closed") {
statusHtml = '🔴 Closed';
} else {
statusHtml = '⚪ Unknown';
}
return '
' +
'
' + escHtml(p.port) + '
' +
'
' + escHtml(p.protocol) + '
' +
'
' + escHtml(p.description) + '
' +
'
' + statusHtml + '
' +
'
';
}).join("");
var ipLine = internalIp
? '
Forward each port below to this machine\'s internal IP: ' + escHtml(internalIp) + '
'
: "
Forward each port below to this machine's internal LAN IP in your router's port forwarding settings.
";
$portReqBody.innerHTML =
'
Port Forwarding Required
' +
'
For ' + escHtml(featureName) + " to work with clients outside your local network, " +
"you must configure port forwarding in your router's admin panel.
" +
ipLine +
'
' +
'
Port(s)
Protocol
Purpose
Status
' +
'' + rows + '' +
'
' +
"
How to verify: Router-side forwarding cannot be checked from inside your network. " +
"To confirm ports are forwarded correctly, test from a device on a different network (e.g. a phone on mobile data) " +
"or check your router's port forwarding page.
" +
'
ℹ Search "how to set up port forwarding on [your router model]" for step-by-step instructions.
' +
'
' +
'' +
continueBtn +
'
';
document.getElementById("port-req-dismiss-btn").addEventListener("click", function() {
closePortRequirementsModal();
});
if (onContinue) {
document.getElementById("port-req-continue-btn").addEventListener("click", function() {
closePortRequirementsModal();
onContinue();
});
}
})
.catch(function() {
// Fallback: show static table without status column if fetch fails
var rows = ports.map(function(p) {
return '
' + escHtml(p.port) + '
' +
'
' + escHtml(p.protocol) + '
' +
'
' + escHtml(p.description) + '
';
}).join("");
$portReqBody.innerHTML =
'
Port Forwarding Required
' +
'
For ' + escHtml(featureName) + ' to work with clients outside your local network, ' +
'you must configure port forwarding in your router\'s admin panel and forward each port below to this machine\'s internal LAN IP.
' +
'
' +
'
Port(s)
Protocol
Purpose
' +
'' + rows + '' +
'
' +
'
ℹ Search "how to set up port forwarding on [your router model]" for step-by-step instructions.
' +
'
' +
'' +
continueBtn +
'
';
document.getElementById("port-req-dismiss-btn").addEventListener("click", function() {
closePortRequirementsModal();
});
if (onContinue) {
document.getElementById("port-req-continue-btn").addEventListener("click", function() {
closePortRequirementsModal();
onContinue();
});
}
});
}
function closePortRequirementsModal() {
if ($portReqModal) $portReqModal.classList.remove("open");
}
if ($portReqClose) {
$portReqClose.addEventListener("click", closePortRequirementsModal);
}
// ── Feature toggle logic ──────────────────────────────────────────
async function performFeatureToggle(featId, enabled, extra) {
// Look up feature name for the rebuild modal
_rebuildIsEnabling = enabled;
_rebuildFeatureName = featId;
if (_featuresData) {
var found = _featuresData.features.find(function(f) { return f.id === featId; });
if (found) _rebuildFeatureName = found.name;
}
try {
var res = await fetch("/api/features/toggle", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ feature: featId, enabled: enabled, extra: extra || {} }),
});
var body = await res.json();
if (!res.ok) {
if (body && body.error === "domain_required") {
alert("Domain not configured for this feature. Please configure it first.");
} else {
alert("Error: " + (body.detail || body.error || "Unknown error"));
}
loadFeatureManager();
return;
}
openRebuildModal();
} catch (err) {
alert("Failed to toggle feature: " + err);
loadFeatureManager();
}
}
function handleFeatureToggle(feat, newEnabled) {
if (!newEnabled) {
// Disable: ask confirmation
openFeatureConfirm(
"This will disable " + feat.name + ". The system will rebuild. Continue?",
function() { performFeatureToggle(feat.id, false, {}); }
);
return;
}
// Enabling
var conflictNames = [];
if (feat.conflicts_with && feat.conflicts_with.length > 0 && _featuresData) {
feat.conflicts_with.forEach(function(cid) {
var cf = _featuresData.features.find(function(f) { return f.id === cid; });
if (cf && cf.enabled) conflictNames.push(cf.name);
});
}
function proceedAfterPortCheck() {
// Check SSL email first
if (!_featuresData || !_featuresData.ssl_email_configured) {
if (feat.needs_domain) {
openSslEmailModal(function() {
// After ssl email saved, check domain
checkDomainAndEnable(feat, {});
});
return;
}
}
if (feat.needs_domain && !feat.domain_configured) {
checkDomainAndEnable(feat, {});
return;
}
if (feat.id === "haven") {
var npub = "";
if (feat.extra_fields) {
var ef = feat.extra_fields.find(function(e) { return e.id === "nostr_npub"; });
if (ef) npub = ef.current_value || "";
}
if (!npub) {
// Need to collect npub via domain modal
openDomainSetupModal(feat, function(collectedNpub) {
performFeatureToggle(feat.id, true, { nostr_npub: collectedNpub });
});
return;
}
}
performFeatureToggle(feat.id, true, {});
}
function proceedAfterConflictCheck() {
// Show port requirements notification if the feature has extra port needs
var ports = feat.port_requirements || [];
if (ports.length > 0) {
openPortRequirementsModal(feat.name, ports, proceedAfterPortCheck);
} else {
proceedAfterPortCheck();
}
}
if (conflictNames.length > 0) {
var confirmMsg;
if (feat.id === "bip110") {
confirmMsg = "Only one Bitcoin node implementation can be active. Enabling Bitcoin Knots + BIP110 will disable Bitcoin Core (if active). Continue?";
} else if (feat.id === "bitcoin-core") {
confirmMsg = "Only one Bitcoin node implementation can be active. Enabling Bitcoin Core will disable Bitcoin Knots + BIP110 (if active). Continue?";
} else {
confirmMsg = "This will disable " + conflictNames.join(", ") + ". Continue?";
}
openFeatureConfirm(confirmMsg, proceedAfterConflictCheck);
} else {
proceedAfterConflictCheck();
}
}
function checkDomainAndEnable(feat, extra) {
openDomainSetupModal(feat, function(collectedNpub) {
var extraData = {};
if (collectedNpub) extraData.nostr_npub = collectedNpub;
performFeatureToggle(feat.id, true, extraData);
});
}
// ── Feature Manager rendering ─────────────────────────────────────
async function loadFeatureManager() {
try {
var data = await apiFetch("/api/features");
_featuresData = data;
// Feature Manager is now integrated into tile modals; sidebar rendering removed.
} catch (err) {
console.warn("Failed to load features:", err);
}
}
function _checkFeatureManagerDomains(data) {
// Collect all features with a configured domain
var featsWithDomain = (data.features || []).filter(function(f) {
return f.needs_domain && f.domain_configured;
});
if (!featsWithDomain.length) return;
// Get the actual domain values from /api/domains/status, then check them
fetch("/api/domains/status")
.then(function(r) { return r.json(); })
.then(function(statusData) {
var domainFileMap = statusData.domains || {};
// Build list of domains to check and a map from domain value → feature id
var domainsToCheck = [];
var domainToFeatIds = {};
featsWithDomain.forEach(function(feat) {
var domainName = feat.domain_name;
var domainVal = domainName ? domainFileMap[domainName] : null;
if (domainVal) {
domainsToCheck.push(domainVal);
if (!domainToFeatIds[domainVal]) domainToFeatIds[domainVal] = [];
domainToFeatIds[domainVal].push(feat.id);
} else {
// Domain file missing — update badge to warn
_updateFeatureDomainBadge(feat.id, null, "unresolvable");
}
});
if (!domainsToCheck.length) return;
return fetch("/api/domains/check", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ domains: domainsToCheck }),
})
.then(function(r) { return r.json(); })
.then(function(checkData) {
(checkData.domains || []).forEach(function(d) {
var featIds = domainToFeatIds[d.domain] || [];
featIds.forEach(function(featId) {
_updateFeatureDomainBadge(featId, d.domain, d.status);
});
});
});
})
.catch(function() {});
}
function _updateFeatureDomainBadge(featId, domainVal, status) {
var section = $sidebarFeatures.querySelector(".feature-manager-section");
if (!section) return;
// Find the card — cards don't have a data-feat-id, so find via name match
var badges = section.querySelectorAll(".feature-domain-badge.configured");
badges.forEach(function(badge) {
var domainNameAttr = badge.getAttribute("data-domain-name");
// Match by domain_name attribute — we need to look up the feat's domain_name
var feat = _featuresData && _featuresData.features
? _featuresData.features.find(function(f) { return f.id === featId; })
: null;
if (!feat) return;
if (domainNameAttr !== (feat.domain_name || "")) return;
var lbl = badge.querySelector(".feature-domain-label");
if (!lbl) return;
lbl.classList.remove("feature-domain-label--checking");
if (status === "connected") {
lbl.className = "feature-domain-label feature-domain-label--ok";
lbl.textContent = (domainVal || "Domain") + " ✓";
} else if (status === "dns_mismatch") {
lbl.className = "feature-domain-label feature-domain-label--warn";
lbl.textContent = (domainVal || "Domain") + " (IP mismatch)";
} else if (status === "unresolvable") {
lbl.className = "feature-domain-label feature-domain-label--error";
lbl.textContent = (domainVal || "Domain") + " (DNS error)";
} else {
lbl.className = "feature-domain-label feature-domain-label--warn";
lbl.textContent = (domainVal || "Domain") + " (unknown)";
}
});
}
function renderFeatureManager(data) {
// Remove old feature manager section if it exists
var old = $sidebarFeatures.querySelector(".feature-manager-section");
if (old) old.parentNode.removeChild(old);
var section = document.createElement("div");
section.className = "category-section feature-manager-section";
section.dataset.category = "feature-manager";
section.innerHTML = '
Feature Manager
';
// Group by sub-category
var grouped = {};
for (var i = 0; i < data.features.length; i++) {
var f = data.features[i];
var cat = f.category || "other";
if (!grouped[cat]) grouped[cat] = [];
grouped[cat].push(f);
}
var orderedCats = FEATURE_SUBCATEGORY_ORDER.filter(function(k) { return grouped[k]; });
Object.keys(grouped).forEach(function(k) {
if (orderedCats.indexOf(k) === -1) orderedCats.push(k);
});
for (var j = 0; j < orderedCats.length; j++) {
var catKey = orderedCats[j];
var feats = grouped[catKey];
if (!feats || feats.length === 0) continue;
var subcat = document.createElement("div");
subcat.className = "feature-subcategory";
var subcatLabel = FEATURE_SUBCATEGORY_LABELS[catKey] || catKey;
subcat.innerHTML = '
' + escHtml(subcatLabel) + '
';
var cardsWrap = document.createElement("div");
cardsWrap.className = "feature-cards-wrap";
for (var k = 0; k < feats.length; k++) {
cardsWrap.appendChild(buildFeatureCard(feats[k]));
}
subcat.appendChild(cardsWrap);
section.appendChild(subcat);
}
$sidebarFeatures.appendChild(section);
}
function buildFeatureCard(feat) {
var card = document.createElement("div");
card.className = "feature-card";
var conflictHtml = "";
if (feat.conflicts_with && feat.conflicts_with.length > 0) {
var conflictNames = feat.conflicts_with.map(function(cid) {
if (!_featuresData) return cid;
var cf = _featuresData.features.find(function(f) { return f.id === cid; });
return cf ? cf.name : cid;
});
conflictHtml = '