openvibers/openvibe.network Commits
Admin: remove reservation keys, Deploy, SSH and the Glossary tab (#904)
Changes
@@@ -68,7 +68,6 @@<button class="admin-tab-btn owner-only" onclick="showTab('storage',this)"><i class="fa-solid fa-hard-drive"></i> Storage</button><button class="admin-tab-btn" onclick="showTab('notifications',this)"><i class="fa-solid fa-bell"></i> Notifications</button><button class="admin-tab-btn owner-only" onclick="showTab('email',this)"><i class="fa-solid fa-envelope"></i> Email</button>-- <button class="admin-tab-btn" onclick="showTab('vkeys',this)"><i class="fa-solid fa-key"></i> Keys</button><button class="admin-tab-btn" onclick="showTab('audit',this)"><i class="fa-solid fa-clipboard-list"></i> Audit</button><button class="admin-tab-btn" onclick="showTab('modlog',this)"><i class="fa-solid fa-gavel"></i> Moderation log</button><button class="admin-tab-btn owner-only" onclick="showTab('grants',this)"><i class="fa-solid fa-key"></i> Service grants</button>@@@ -77,9 +76,6 @@<button class="admin-tab-btn owner-only" onclick="showTab('eventsops',this)"><i class="fa-solid fa-route"></i> Event deliveries</button><button class="admin-tab-btn" onclick="showTab('analytics',this)"><i class="fa-solid fa-chart-column"></i> Analytics</button><button class="admin-tab-btn owner-only" onclick="showTab('domains',this)"><i class="fa-solid fa-globe"></i> Domains</button>-- <button class="admin-tab-btn owner-only" onclick="showTab('deploy',this)"><i class="fa-solid fa-server"></i> Deploy</button>-- <button class="admin-tab-btn" onclick="showTab('ssh',this)"><i class="fa-solid fa-terminal"></i> SSH</button>-- <button class="admin-tab-btn" onclick="showTab('glossary',this)"><i class="fa-solid fa-book"></i> Glossary</button></div></div><button class="tabs-scroll-btn" id="main-tabs-right" onclick="scrollMainTabs(1)" title="Scroll right" aria-label="Scroll right">@@@ -98,10 +94,6 @@<div id="setup-content" class="setup"></div></div>-- <!-- Glossary: the few words the panel cannot avoid, in plain words (js/admin/glossary.js) -->-- <div class="section" id="sec-glossary">-- <div id="glossary-content"></div>-- </div><!-- Dashboard --><div class="section active admin-clarity" id="sec-dashboard">@@@ -294,17 +286,6 @@</div></div>-- <!-- Verification Keys -->-- <div class="section admin-clarity" id="sec-vkeys">-- <p class="admin-intro">Account reservation keys let a person claim a reserved username. Generate one when arranging a reservation; revoking it is permanent, so create a new key if the person still needs access.</p>-- <div class="input-row">-- <span class="admin-field"><input type="text" id="vkey-username" aria-label="Username to reserve" aria-describedby="help-vkey-username" placeholder="Username to reserve..." onkeydown="if(event.key==='Enter')generateVKey()"><span class="admin-help" id="help-vkey-username"><strong>Username to reserve.</strong> Enter the username the recipient will claim, for example alex. No name is selected by default.</span></span>-- <span class="admin-field"><input type="text" id="vkey-note" aria-label="Reservation note" aria-describedby="help-vkey-note" placeholder="Note (optional)"><span class="admin-help" id="help-vkey-note"><strong>Reservation note.</strong> Optional note for staff, for example reserved for the event host. Blank adds no note.</span></span>-- <button class="btn btn-primary" onclick="generateVKey()"><i class="fa-solid fa-key"></i> Generate a reservation key</button>-- </div>-- <div id="vkeys-content"><div class="loading">Loading...</div></div>-- </div>--<!-- Audit Log --><div class="section admin-clarity" id="sec-audit"><p class="admin-intro">This is the history of administrative changes. Review it after an unexpected change, then use the relevant settings page to correct it.</p>@@@ -556,145 +537,10 @@</div></div>-- <!-- Deploy -->-- <div class="section admin-clarity" id="sec-deploy">-- <p class="admin-intro">These tools manage website addresses, HTTPS certificates, and the web server. Change them when bringing a domain online or fixing routing, and review previews before applying changes.</p>-- <div class="sub-tabs" id="deploy-sub-tabs">-- <button class="active" onclick="showDeploySubTab('overview',this)">Overview</button>-- <button onclick="showDeploySubTab('certs',this)">Certificates</button>-- <button onclick="showDeploySubTab('nginx',this)">Nginx</button>-- <button onclick="showDeploySubTab('domains',this)">Domains</button>-- <button onclick="showDeploySubTab('config',this)">Config</button>-- </div>---- <!-- Deploy: Overview -->-- <div id="deploy-overview">-- <div class="stats-grid" id="deploy-stats"><div class="loading">Loading deploy status...</div></div>-- <div class="card" style="margin-top:16px">-- <h3><i class="fa-solid fa-circle-check"></i> Prerequisites</h3><p class="admin-intro">These checks show whether certificate and web server tools are installed and configured. Fix a missing prerequisite before issuing certificates or applying web routing.</p>-- <div id="deploy-prereqs"><div class="muted">Checking...</div></div>-- </div>-- </div>---- <!-- Deploy: Certificates -->-- <div id="deploy-certs-panel" style="display:none">-- <div class="card">-- <h3><i class="fa-solid fa-lock"></i> SSL/TLS Certificates</h3>-- <p class="muted" style="margin:8px 0 16px">Certificates enable encrypted HTTPS connections for your domains. Check expiry dates when a certificate is near renewal or visitors see a security error; fewer remaining days means renewal is more urgent.</p>-- <div id="deploy-certs-list"><div class="loading">Loading certificates...</div></div>-- </div>-- <div class="card" style="margin-top:16px">-- <h3><i class="fa-solid fa-plus-circle"></i> Issue New Certificate</h3><p class="admin-intro">This requests HTTPS certificates for a domain you control. Use it when adding a domain; renewal replaces expiring certificates, and issuance cannot be recalled from this page.</p>-- <div class="form-group" style="margin:12px 0">-- <label>Domain</label>-- <span class="admin-field"><input type="text" id="cert-issue-domain" aria-label="Domain for the certificate" aria-describedby="help-cert-issue-domain" placeholder="example.com" style="width:300px"><span class="admin-help" id="help-cert-issue-domain"><strong>Domain for the certificate.</strong> No domain is selected by default. Enter a domain you control, for example example.com, after its DNS is ready.</span></span>-- </div>-- <div style="display:flex;gap:8px;flex-wrap:wrap">-- <button class="btn btn-primary" onclick="issueCertCloudflare()">-- <i class="fa-brands fa-cloudflare"></i> Issue via Cloudflare DNS-- </button>-- <button class="btn btn-secondary" onclick="showManualDnsInfo()">-- <i class="fa-solid fa-hand"></i> Manual DNS-- </button>-- <button class="btn btn-secondary" onclick="renewAllCerts()">-- <i class="fa-solid fa-rotate"></i> Renew all certificates-- </button>-- </div>-- <div id="cert-issue-output" style="margin-top:12px"></div>-- </div>-- </div>---- <!-- Deploy: Nginx -->-- <div id="deploy-nginx-panel" style="display:none">-- <div class="card">-- <h3><i class="fa-solid fa-sitemap"></i> Nginx Configuration</h3>-- <p class="muted" style="margin:8px 0 16px">Nginx directs web requests to the right service. Preview when changing domains, then validate before applying; Apply writes configuration and reloads the web server, and undo requires restoring the backup on the server.</p>-- <div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px">-- <button class="btn btn-primary" onclick="previewNginxConfigs()">-- <i class="fa-solid fa-eye"></i> Preview All Configs-- </button>-- <button class="btn btn-secondary" onclick="validateNginx()">-- <i class="fa-solid fa-check-circle"></i> Validate Current Config-- </button>-- <button class="btn btn-warning" onclick="applyNginxConfigs()">-- <i class="fa-solid fa-upload"></i> Apply configuration and reload-- </button>-- </div>-- <div id="nginx-preview-output"></div>-- </div>-- </div>---- <!-- Deploy: Domains -->-- <div id="deploy-domains-panel" style="display:none">-- <div class="card">-- <h3><i class="fa-solid fa-globe"></i> Managed Domains</h3>-- <p class="muted" style="margin:8px 0 16px">These domains are included in certificate and web routing configuration. Add a domain when bringing it online, or remove it to stop managing it here; add it again to undo.</p>-- <div id="deploy-domains-list"><div class="loading">Loading...</div></div>-- <div style="margin-top:16px;display:flex;gap:8px;align-items:end;flex-wrap:wrap">-- <div class="form-group" style="margin:0">-- <label>Add Domain</label>-- <span class="admin-field"><input type="text" id="domain-add-input" aria-label="Domain to manage" aria-describedby="help-domain-add-input" placeholder="example.com" style="width:250px"><span class="admin-help" id="help-domain-add-input"><strong>Domain to manage.</strong> No domain is selected by default. Enter the base domain, for example example.com; removing it later reverses this list change.</span></span>-- </div>-- <label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text-secondary)">-- <span class="admin-field"><input type="checkbox" id="domain-add-wildcard" aria-label="Include subdomains" aria-describedby="help-domain-add-wildcard" checked><span class="admin-help" id="help-domain-add-wildcard"><strong>Include subdomains.</strong> On by default, so the certificate also covers addresses such as app.example.com. Turn it off to request only the base domain.</span></span> Wildcard (*.domain)-- </label>-- <button class="btn btn-primary" onclick="addManagedDomain()">-- <i class="fa-solid fa-plus"></i> Add domain-- </button>-- </div>-- </div>-- </div>---- <!-- Deploy: Config -->-- <div id="deploy-config-panel" style="display:none">-- <div class="card">-- <h3><i class="fa-solid fa-gear"></i> Deploy Configuration</h3><p class="admin-intro">These settings choose how certificates and web routing are managed. Change them when setting up the server, using Preview only until the generated configuration has been checked.</p>-- <div class="form-group" style="margin:12px 0">-- <label for="deploy-acme-email">Certificate contact email</label>-- <span class="admin-field"><input type="email" id="deploy-acme-email" aria-label="Certificate contact email" aria-describedby="help-deploy-acme-email" placeholder="[email protected]" style="width:350px"><span class="admin-help" id="help-deploy-acme-email">No contact address is configured by default. Enter an address you monitor, for example [email protected], for certificate account notices.</span></span>-- </div>-- <div class="form-group" style="margin:12px 0">-- <label for="deploy-cert-mode">How certificates are obtained</label>-- <span class="admin-field"><select id="deploy-cert-mode" aria-label="How to obtain certificates" aria-describedby="help-deploy-cert-mode" style="width:250px">-- <option value="cloudflare">Automatic — a Cloudflare token does the DNS step</option>-- <option value="manual">Manual — you add the DNS record yourself</option>-- <option value="none">External — managed outside Network</option>-- </select><span class="admin-help" id="help-deploy-cert-mode">Manual DNS is the default and requires adding a verification record yourself. Cloudflare automates that step with a token; External means certificates are managed elsewhere.</span></span>-- </div>-- <div class="form-group" style="margin:12px 0" id="deploy-cf-token-group">-- <label for="deploy-cf-token">Cloudflare DNS token <span class="muted">(needs Zone → DNS → Edit)</span></label>-- <span class="admin-field"><input type="password" id="deploy-cf-token" aria-label="Cloudflare DNS editing token" aria-describedby="help-deploy-cf-token" placeholder="••••••••" style="width:400px"><span class="admin-help" id="help-deploy-cf-token">No token is configured by default. Use a token with Zone → DNS → Edit for the managed domains; for example limit it to your website zone.</span></span>-- <div class="muted" style="font-size:11px;margin-top:4px">Token with Zone:DNS:Edit permissions for all managed domains</div>-- </div>-- <div class="form-group" style="margin:12px 0">-- <label for="deploy-nginx-mode">How the web server is managed</label>-- <span class="admin-field"><select id="deploy-nginx-mode" aria-label="How to manage the web server" aria-describedby="help-deploy-nginx-mode" style="width:250px">-- <option value="preview">Preview only — nothing is written</option>-- <option value="apply">Apply — write, check and reload the web server</option>-- <option value="disabled">Disabled — do not manage Nginx</option>-- </select><span class="admin-help" id="help-deploy-nginx-mode">Preview only is the default and writes nothing. Apply permits writing, checking, and reloading Nginx; Disabled turns management off, and restoring a backup on the server undoes applied changes.</span></span>-- </div>-- <div class="form-group" style="margin:12px 0">-- <label for="deploy-nginx-path">Web server configuration folder</label>-- <span class="admin-field"><input type="text" id="deploy-nginx-path" aria-label="Web server configuration folder" aria-describedby="help-deploy-nginx-path" placeholder="/etc/nginx/sites-enabled" style="width:400px"><span class="admin-help" id="help-deploy-nginx-path">The default is /etc/nginx/sites-enabled. Change it only if this server stores enabled website configuration in another folder.</span></span>-- </div>-- <button class="btn btn-primary" onclick="saveDeployConfig()" style="margin-top:8px">-- <i class="fa-solid fa-save"></i> Save deploy settings-- </button>-- <span id="deploy-config-status" style="margin-left:12px"></span>-- </div>-- </div>-- </div>---- <!-- SSH access provisioning --><div class="section admin-clarity" id="sec-domains"><p class="admin-intro">These addresses route people to tools across OpenVibe. Add or change an address when introducing a domain; remove the saved override to return to the built-in routing.</p><div id="domains-content"><div class="loading">Loading...</div></div></div>-- <div class="section admin-clarity" id="sec-ssh">-- <p class="admin-intro">SSH gives staff command-line access to the development server. Use these instructions when setting up access, and choose only the permissions the person needs.</p>-- <div id="ssh-content"><div class="loading">Loading...</div></div>-- </div></div><!-- Shared scripts -->@@@ -722,7 +568,6 @@<script src="/js/admin/setup.js" defer></script><script src="/js/admin/content.js" defer></script><script src="/js/admin/analytics.js" defer></script>--<script src="/js/admin/deploy.js" defer></script><script src="/js/admin/boot.js" defer></script><script src="https://openvibe.live/live-notify.js" async></script></body>@@@ -155,9 +155,6 @@ a.btn{text-decoration:none}.tabs-scroll-btn{width:30px;height:30px;flex-basis:30px;border-radius:8px}}.ssh-prompt{background:var(--bg-elev,#111);border:1px solid var(--border,#2a2a2a);border-radius:8px;padding:14px;white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:0.8rem;line-height:1.55;color:var(--text,#ddd);max-height:520px;overflow:auto;margin:0}--#sec-ssh code,#sec-payments code,#sec-cashouts code{background:var(--bg-elev,#111);border:1px solid var(--border,#2a2a2a);border-radius:5px;padding:2px 7px;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:0.8rem;display:inline-block;margin-top:3px;word-break:break-all}--#sec-ssh .staff-pill{font-size:0.62rem;font-weight:700;text-transform:uppercase;letter-spacing:.04em;padding:3px 8px;border-radius:999px;vertical-align:middle;margin-left:8px;color:var(--warning-text, var(--warning,#f0b74a));background:color-mix(in srgb,var(--warning,#f0b74a) 18%,transparent);border:1px solid color-mix(in srgb,var(--warning,#f0b74a) 40%,transparent)}--#sec-ssh .staff-pill.user-pill{color:var(--accent,#6ea8ff);background:color-mix(in srgb,var(--accent,#6ea8ff) 18%,transparent);border-color:color-mix(in srgb,var(--accent,#6ea8ff) 40%,transparent)}/* ── Platform AI (AI Exchange D11 S3, owner only): one long page, sticky index, no inner tabs ───────────────────── */@@@ -398,22 +395,12 @@ body:has(.admin-clarity.active) .ov-sel-opt { min-height:44px; }.setup-foot a{color:var(--accent-light)}.setup-services ul{margin:6px 0 0 18px;font-size:12.5px;line-height:1.7;color:var(--text-secondary)}--/* ── Glossary (/admin#glossary) ── */--.glossary{max-width:820px}--.glossary h2{font-size:20px;font-weight:800;margin-bottom:6px}--.glossary-intro{font-size:13px;line-height:1.55;margin-bottom:14px}--.glossary-list{display:grid;gap:8px}--.glossary-item{padding:12px 14px;background:var(--bg-card);border:1px solid var(--border);border-radius:10px;scroll-margin-top:80px;transition:border-color .3s}--.glossary-item.is-target{border-color:var(--accent)}--.glossary-item dt{font-size:14px;font-weight:700;margin-bottom:3px}--.glossary-item dd{font-size:13px;line-height:1.55;color:var(--text-secondary)}--@media(max-width:800px){.setup-row{grid-template-columns:minmax(0,1fr)}}/* Phones: every control at least 44 px, nothing wider than the screen. */@media(max-width:640px){.section-tabs button,.tabs-scroll-btn{min-height:44px}.tabs-scroll-btn{width:44px;flex-basis:44px}-- #sec-setup .btn,#sec-setup input:not([type=checkbox]),#sec-setup summary,#sec-glossary .btn,.ov-what-dialog .btn,#platform-ai .pai-advanced>summary,++ #sec-setup .btn,#sec-setup input:not([type=checkbox]),#sec-setup summary,.ov-what-dialog .btn,#platform-ai .pai-advanced>summary,#sec-ai .btn,#sec-ai input:not([type=checkbox]),#sec-ai select,#sec-ai textarea,#sec-ai summary,#sec-ai .ov-sel{min-height:44px}#sec-setup input[type=checkbox]{width:44px;height:44px}.ov-what,.setup-foot a{display:inline-flex;align-items:center;min-height:44px}@@@ -8,7 +8,6 @@const OV_PAGE_LINKS = [{"label":"Network","href":"/"},{"label":"Link","href":"https://openvibe.link/","icon":"fa-desktop","featured":true},{"label":"My Account","href":"https://openvibe.my/"},{"label":"Wallet","href":"/wallet"},{"label":"Themes","href":"/themes"}];const tabLoaders = {setup: loadSetup,-- glossary: loadGlossary,dashboard: loadDashboard,users: loadUsers,streams: loadStreams,@@@ -27,7 +26,6 @@ const tabLoaders = {storage: loadStorage,notifications: () => {},email: loadEmail,-- vkeys: loadVKeys,audit: loadAudit,modlog: () => loadModLog(true),grants: () => loadGrants(),@@@ -35,9 +33,7 @@ const tabLoaders = {checklist: () => loadOperatorChecklist(),eventsops: () => loadDeliveries(),analytics: loadAnalytics,-- deploy: loadDeployOverview,domains: loadDomains,-- ssh: loadSSH,};// ═══════════════════════════════════════════════════════════════@@@ -84,7 +84,7 @@ async function api(path, opts = {}) {return data;}--// Auth headers for raw fetch() calls (SSH tab, Deploy tab, etc.).++// Auth headers for raw fetch() calls.function authHeaders() {const h = {};if (token) h.Authorization = 'Bearer ' + token;@@@ -99,7 +99,7 @@ let currentTab = 'dashboard';// 'setup' is the owner's checklist of keys and accounts (js/admin/setup.js); its API answers 403 to anyone else too.// The owner's alone (2026-11-13: Notifications too, "we don't want admins to be able to do this"). Email is open to// admins as a read-only view of its delivery numbers; its settings and changes are the owner's (server/admin/routes.js).--const OWNER_ONLY_TABS = new Set(['guide', 'setup', 'ai-setup', 'deploy', 'domains', 'payments', 'cashouts', 'storage', 'notifications', 'settings', 'grants', 'eventsops']);++const OWNER_ONLY_TABS = new Set(['guide', 'setup', 'ai-setup', 'domains', 'payments', 'cashouts', 'storage', 'notifications', 'settings', 'grants', 'eventsops']);function showTab(id, btn) {if (!tabLoaders[id]) id = 'dashboard';@@@ -121,7 +121,6 @@ function showTab(id, btn) {/admin/<tab>[/<subtab>][?last=30d] e.g. /admin/settings,/admin/analytics/openvibelive?last=12h. On load a slug wins; otherwise thelast-visited location is restored from localStorage. */--let deployCurrentSubTab = 'overview';let _applyingRoute = false;function _tabButton(id) {@@@ -154,7 +153,6 @@ function urlParamToPeriod(p) {function _adminPath() {if (currentTab === 'ai-setup') return '/admin/ai/setup';if (currentTab === 'analytics') return '/admin/analytics/' + (analyticsCurrentSubTab || 'overview');-- if (currentTab === 'deploy') return '/admin/deploy/' + (deployCurrentSubTab || 'overview');if (currentTab && currentTab !== 'dashboard') return '/admin/' + currentTab;return '/admin';}@@@ -199,9 +197,8 @@ function routeFromUrl() {const sel = document.getElementById('analytics-period'); if (sel) sel.value = per;}showTab(tab, _tabButton(tab));-- // Use the effective tab (showTab may bounce a non-owner off /admin/deploy).++ // Use the effective tab (showTab may bounce a non-owner off an owner's tab).if (currentTab === 'analytics' && sub) showAnalyticsSubTab(sub, _subBtn('analytics-sub-tabs', sub));-- else if (currentTab === 'deploy' && sub) showDeploySubTab(sub, _subBtn('deploy-sub-tabs', sub));} finally {_applyingRoute = false;_syncAdminUrl(true); // normalize (e.g. /admin/analytics -> /admin/analytics/overview)ddeleted file mode 100644@@@ -1,633 +0,0 @@--/* ═══════════════════════════════════════════════════════════════-- OpenVibe.Network — admin panel (/admin): domains, SSH access and the deploy tab.-- Split out of admin.html. Classic scripts, global scope: they rely on-- admin/core.js's helpers (api, esc, toast, API, token) and on the-- tabLoaders map in admin/boot.js (loaded last). No ES modules.-- ═══════════════════════════════════════════════════════════════ */--// ── Tool domains (owner only; the server enforces it) ───────────────--let _domCatalog = null;--async function loadDomains() {-- const el = document.getElementById('domains-content');-- try {-- const [list, cat] = await Promise.all([api('/api/admin/domains'), _domCatalog ? Promise.resolve(_domCatalog) : api('/api/admin/domains/catalog')]);-- _domCatalog = cat;-- const byTool = {}; list.domains.forEach(d => { (byTool[d.tool_id] = byTool[d.tool_id] || []).push(d); });-- const opts = [...cat.families.map(f => ({ id: f.id, name: f.name + ' (family)' })), ...cat.tools.map(t => ({ id: t.id, name: t.name }))];-- const roleBadge = r => `<span class="badge ${r === 'canonical' ? 'badge-success' : r === 'short' ? 'badge-info' : ''}">${esc({canonical:"Main address for search engines",short:"Short public address",alias:"Redirect to the short address",mirror:"Additional address serving the tool"}[r] || adminWords(r))}</span>`;-- el.innerHTML = `-- <div class="card"><h3><i class="fa-solid fa-globe"></i> Tool domains</h3>-- <p class="muted">Every tool has a <b>canonical</b> host (used in links, sitemaps and canonical tags), a <b>short</b> host people type, any number of <b>aliases</b> that redirect to the short one, and <b>mirrors</b> that serve the tool on another address while pointing search engines at the canonical host. Custom domains work the same way: point DNS at the server, add the host here, then run <code>add-custom-domain.sh</code> on the host for nginx and TLS. Tools picks changes up within a minute.${cat.source !== 'live' ? ' <br><span class="badge badge-warning">Tools catalog unreachable: showing the built-in list</span>' : ''}</p>-- <div class="form-group" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:10px;align-items:end">-- <div class="form-field"><label for="dom-tool">Tool</label><select id="dom-tool" aria-label="Tool receiving this address" aria-describedby="help-dom-tool">${opts.map(o => `<option value="${esc(o.id)}">${esc(o.name)}</option>`).join('')}</select><span class="admin-help" id="help-dom-tool">Choose the tool or family that should open at the domain. The first catalog item is selected initially; choose the intended tool before adding the host.</span></div>-- <div class="form-field"><label for="dom-host">Host</label><input id="dom-host" aria-label="Website hostname" aria-describedby="help-dom-host" placeholder="youtubedownloadonline.com" autocomplete="off" spellcheck="false"><span class="admin-help" id="help-dom-host">No hostname is entered by default. Enter a hostname without https://, for example tools.example.com, after pointing its DNS at the server.</span></div>-- <div class="form-field"><label for="dom-role">Role</label><select id="dom-role" aria-label="How this address works" aria-describedby="help-dom-role"><option value="alias">Redirect — sends visitors to the short address</option><option value="mirror">Mirror — serves the tool here, and points search engines at the main address</option><option value="short">Short — the address people type</option><option value="canonical">Main — the address search engines are pointed at</option></select><span class="admin-help" id="help-dom-role">Redirect is the default and sends visitors to the short address. Mirror serves the tool at this address, Short sets its public address, and Main sets its preferred search-engine address.</span></div>-- <div class="form-field"><label for="dom-note">Note</label><input id="dom-note" aria-label="Staff note" aria-describedby="help-dom-note" maxlength="500"><span class="admin-help" id="help-dom-note">Optional note, blank by default. For example: campaign domain for November.</span></div>-- <div><button class="btn btn-outline" onclick="checkDomainDns()">Check DNS</button> <button class="btn btn-primary" onclick="addDomain()">Add</button></div>-- </div>-- <div id="dom-check" class="muted" style="margin-top:8px"></div>-- </div>-- <div class="card"><h3>Overrides</h3><p class="admin-intro">These saved addresses replace or extend the built-in tool addresses. Change them when routing a custom domain; delete the override to return to built-in routing.</p>-- ${list.domains.length ? `<div style="overflow-x:auto"><table class="table" style="width:100%"><thead><tr><th>Tool</th><th>Host</th><th>Role</th><th>On</th><th>Note</th><th></th></tr></thead><tbody>-- ${list.domains.map(d => `<tr><td>${esc(d.tool_id)}${d.tool_known === false ? ' <span class="badge badge-warning">unknown tool</span>' : ''}</td>-- <td><a href="https://${esc(d.host)}/" target="_blank" rel="noopener">${esc(d.host)}</a></td><td>${roleBadge(d.role)}</td>-- <td><input type="checkbox" ${d.enabled ? 'checked' : ''} onchange="updateDomain(${d.id},{enabled:this.checked})" aria-label="Enabled"></td>-- <td>${esc(d.note || '')}</td>-- <td style="white-space:nowrap"><select onchange="updateDomain(${d.id},{role:this.value})" aria-label="Role">${['canonical','short','alias','mirror'].map(r => `<option value="${r}" ${r === d.role ? 'selected' : ''}>${{canonical:'Main address for search engines',short:'Short public address',alias:'Redirect to the short address',mirror:'Additional address serving the tool'}[r]}</option>`).join('')}</select>-- <button class="btn btn-sm btn-danger" onclick="deleteDomain(${d.id},'${esc(d.host)}')">Remove override</button></td></tr>`).join('')}-- </tbody></table></div>` : '<p class="muted">No overrides yet; tools use their built-in addresses. Add a domain above when you need a custom address.</p>'}-- </div>-- <div class="card"><h3>Built-in hosts</h3><p class="admin-intro">These are the default addresses supplied by the tool catalog. Check them before adding an override so you know which address it replaces.</p><div style="overflow-x:auto"><table class="table" style="width:100%"><thead><tr><th>Tool</th><th>Canonical</th><th>Short</th><th>Aliases</th></tr></thead><tbody>-- ${cat.tools.map(t => `<tr><td>${esc(t.name)} <small class="muted">${esc(t.id)}</small></td><td>${esc(t.hosts?.canonical || '')}</td><td>${esc(t.hosts?.short || '')}</td><td>${esc((t.hosts?.aliases || []).join(', '))}</td></tr>`).join('')}-- </tbody></table></div></div>`;-- } catch (e) { el.innerHTML = `<div class="card"><p class="muted">Could not load domains: ${esc(e.message)}</p></div>`; }--}--async function addDomain() {-- const body = { tool_id: document.getElementById('dom-tool').value, host: document.getElementById('dom-host').value, role: document.getElementById('dom-role').value, note: document.getElementById('dom-note').value };-- try { const r = await api('/api/admin/domains', { method: 'POST', body }); toast('Domain added', 'success'); (r.warnings || []).forEach(w => toast(w, 'warning')); loadDomains(); } catch (e) { toast(e.message, 'error'); }--}--async function updateDomain(id, patch) { try { const r = await api('/api/admin/domains/' + id, { method: 'PUT', body: patch }); (r.warnings || []).forEach(w => toast(w, 'warning')); toast('Saved', 'success'); } catch (e) { toast(e.message, 'error'); } loadDomains(); }--async function deleteDomain(id, host) { if (!confirm(`Remove ${host} from the tool address overrides? This changes routing after refresh. Add the same host and role again to undo.`)) return; try { await api('/api/admin/domains/' + id, { method: 'DELETE' }); toast('Removed', 'success'); } catch (e) { toast(e.message, 'error'); } loadDomains(); }--async function checkDomainDns() {-- const out = document.getElementById('dom-check'); out.textContent = 'Checking…';-- try { const r = await api('/api/admin/domains/check', { method: 'POST', body: { host: document.getElementById('dom-host').value } }); const c = r.check || {};-- out.textContent = (c.status === 'ready' ? 'Ready. ' : '') + (c.message || c.status || ''); } catch (e) { out.textContent = e.message; }--}----// ═══════════════════════════════════════════════════════════════--// SSH Access — owner provisions locked-down dev accounts; admins get--// guided instructions + an agent prompt to connect and vibe-code.--// ═══════════════════════════════════════════════════════════════--let sshInfo = null;----async function loadSSH() {-- const el = document.getElementById('ssh-content');-- if (!el) return;-- if (!sshInfo) {-- try {-- const r = await fetch(`${API}/api/admin/ssh-info`, { headers: authHeaders() });-- const d = await r.json();-- if (!d.ok) throw new Error(d.error || 'Failed to load SSH info');-- sshInfo = d;-- } catch (e) {-- el.innerHTML = `<div class="card"><p class="muted">Could not load SSH info: ${esc(e.message)}</p></div>`;-- return;-- }-- }-- if (sshInfo.is_owner) renderSSHOwner(el);-- else renderSSHUser(el);--}----function _sshSetText(id, t) { const e = document.getElementById(id); if (e) e.textContent = t; }--function _sshSanitizeUser(v) { return (v || '').trim().toLowerCase().replace(/[^a-z0-9_-]/g, ''); }----function sshCopy(id, btn) {-- const el = document.getElementById(id);-- if (!el) return;-- navigator.clipboard.writeText(el.textContent).then(() => {-- if (typeof toast === 'function') toast('Prompt copied', 'success');-- if (btn) { const o = btn.innerHTML; btn.innerHTML = '<i class="fa-solid fa-check"></i> Copied'; setTimeout(() => { btn.innerHTML = o; }, 1400); }-- }).catch(() => { if (typeof toast === 'function') toast('Copy failed', 'error'); });--}----// ── Owner view ──────────────────────────────────────────────--function renderSSHOwner(el) {-- const hostWarn = sshInfo.host ? '' :-- `<div class="card" style="border-color:var(--warning)"><p style="color:var(--warning-text, var(--warning));margin:0"><i class="fa-solid fa-triangle-exclamation"></i> No server host is configured yet. Set <code>SSH_SERVER_HOST</code> in the openvibe-network environment so the connection details fill in automatically.</p></div>`;-- el.innerHTML = `-- <div class="card">-- <h3><i class="fa-solid fa-user-lock"></i> Provision an SSH dev account <span class="staff-pill">Owner</span></h3>-- <p class="muted">Create a locked-down Linux account so a trusted admin can SSH in and help build the sites (vibe coding with Claude). They get their own home + group access to the project folders — <b>no root</b>. Fill in the fields, copy the prompt, and hand it to your agent.</p>-- </div>-- ${hostWarn}-- <div class="card">-- <div class="form-group" style="margin-bottom:14px">-- <label>New Linux username</label>-- <input type="text" id="ssh-o-user" aria-label="New server username" aria-describedby="help-ssh-o-user" placeholder="e.g. alex" oninput="sshOwnerUpdate()" style="max-width:280px"><span class="admin-help" id="help-ssh-o-user">No name is entered by default. Use a Linux username such as alex for the account you are provisioning.</span>-- </div>-- <div class="form-group" style="margin-bottom:14px">-- <label>Their SSH <b>public</b> key <span class="muted">— ask them to run the steps in their own SSH tab and paste their <code>.pub</code> here (recommended)</span></label>-- <textarea id="ssh-o-pubkey" aria-label="SSH public key" aria-describedby="ssh-public-help" rows="3" placeholder="ssh-ed25519 AAAA... alex@openvibe" oninput="sshOwnerUpdate()" style="width:100%;font-family:ui-monospace,monospace;font-size:0.82rem"></textarea><p class="admin-help" id="ssh-public-help">No key is entered by default. Paste the person’s public .pub key, for example an ssh-ed25519 public key; never enter their private key.</p>-- </div>-- <div class="form-group">-- <label>Access level</label>-- <select id="ssh-o-access" aria-label="Server access level" aria-describedby="help-ssh-o-access" onchange="sshOwnerUpdate()" style="max-width:440px">-- <option value="code">Project code only (safest)</option>-- <option value="restart">Project code + restart app services</option>-- <option value="web">Full web + nginx — build & run their own sites (high trust)</option>-- </select><span class="admin-help" id="help-ssh-o-access">Project code only is the default. Restart also allows service restarts, and Full web permits web server management; removing access requires the owner to update the server account.</span>-- </div>-- <div id="ssh-o-webwarn" style="display:none;margin-top:12px;padding:10px 12px;border:1px solid var(--warning);border-radius:8px;background:color-mix(in srgb,var(--warning) 10%,transparent);color:var(--warning-text, var(--warning));font-size:0.84rem;line-height:1.5">-- <i class="fa-solid fa-triangle-exclamation"></i> <b>High trust:</b> web + nginx access lets this admin edit any nginx site config and reload the web server — they can serve anything and a bad config can take <em>all</em> the sites down. It still stops short of root (no package installs or arbitrary sudo), but only grant it to admins you fully trust to run the web layer with you.-- </div>-- </div>-- <div class="card">-- <div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">-- <h3 style="margin:0"><i class="fa-solid fa-robot"></i> Agent prompt</h3>-- <button class="btn btn-primary" onclick="sshCopy('ssh-o-prompt', this)"><i class="fa-solid fa-copy"></i> Copy the prompt</button>-- </div>-- <p class="muted" style="margin:8px 0">Paste into Claude Code running on the server with sudo (or locally with root SSH) to create and lock down the account.</p>-- <pre id="ssh-o-prompt" class="ssh-prompt"></pre>-- </div>-- <div class="card">-- <h3><i class="fa-solid fa-shield-halved"></i> How it's locked down</h3>-- <p class="admin-intro">This lists the limits placed on a provisioned server account. Read it before you grant access, and after any change to the access level you chose above.</p>-- <ul class="muted" style="line-height:1.7;margin:0;padding-left:20px">-- <li>Key-only login — password auth is disabled for the account.</li>-- <li>Member of a shared <code>devs</code> group that owns the project dirs (<code>${esc(sshInfo.projectsRoot)}</code>) with setgid, so they can edit code and new files stay group-shared.</li>-- <li><b>Not</b> in <code>sudo</code> — no blanket root. TLS / packages / arbitrary system changes stay with you.</li>-- <li><b>Access levels</b> are additive and each grants only a tightly-scoped sudoers rule (never general root):-- <ul style="margin:4px 0 0;padding-left:18px">-- <li><b>Project code only</b> — edit code in the project dirs, nothing privileged.</li>-- <li><b>+ Restart app services</b> — adds sudo for <code>systemctl restart openvibe-network/openvibe-live</code> only.</li>-- <li><b>Full web + nginx</b> — adds group ownership of the nginx site configs + a <code>/srv/www</code> web root, plus sudo limited to <code>nginx -t</code> and reload/restart of nginx & php-fpm. Lets them build & run their own sites — high trust, since a bad config affects every site.</li>-- </ul>-- </li>-- <li>Revoke anytime: remove their key from <code>~/.ssh/authorized_keys</code>, or <code>sudo deluser --remove-home <user></code>.</li>-- </ul>-- </div>`;-- sshOwnerUpdate();--}----function sshOwnerUpdate() {-- const user = _sshSanitizeUser(document.getElementById('ssh-o-user')?.value) || '<username>';-- const pub = (document.getElementById('ssh-o-pubkey')?.value || '').trim();-- const access = document.getElementById('ssh-o-access')?.value || 'code';-- const host = sshInfo.host || '<server-host>';-- const roots = sshInfo.projectsRoot;-- const firstRoot = roots.split(/\s+/)[0];-- const pubShown = pub || "<PASTE THE USER'S SSH PUBLIC KEY HERE>";-- const isRestart = access === 'restart' || access === 'web';-- const isWeb = access === 'web';---- // Toggle the high-trust warning under the selector.-- const ww = document.getElementById('ssh-o-webwarn');-- if (ww) ww.style.display = isWeb ? 'block' : 'none';---- const steps = [-- `Create the user with no password login:\n sudo adduser --disabled-password --gecos "" ${user}`,-- `Install their SSH public key:\n sudo install -d -m 700 -o ${user} -g ${user} /home/${user}/.ssh\n echo "${pubShown}" | sudo tee /home/${user}/.ssh/authorized_keys\n sudo chmod 600 /home/${user}/.ssh/authorized_keys\n sudo chown ${user}:${user} /home/${user}/.ssh/authorized_keys`,-- `Create/ensure a shared dev group and add them:\n sudo groupadd -f devs\n sudo usermod -aG devs ${user}`,-- `Give the group access to the project dirs (setgid so new files stay shared):\n sudo chgrp -R devs ${roots}\n sudo chmod -R g+rwX ${roots}\n sudo find ${roots} -type d -exec chmod g+s {} +`,-- `Do NOT add them to the sudo group — they must not have blanket root.`,-- ];-- if (isWeb) {-- steps.push(`Grant broad web + nginx access (edit any site config, run their own sites):\n sudo groupadd -f webdev\n sudo usermod -aG webdev ${user}\n # let the group own & edit nginx site configs (setgid keeps new files group-shared)\n sudo chgrp -R webdev /etc/nginx/sites-available /etc/nginx/sites-enabled\n sudo chmod -R g+rwX /etc/nginx/sites-available /etc/nginx/sites-enabled\n sudo find /etc/nginx/sites-available /etc/nginx/sites-enabled -type d -exec chmod g+s {} +\n # a shared web root for their own sites (2775 = group-writable + setgid)\n sudo install -d -m 2775 -o root -g webdev /srv/www`);-- }-- if (isRestart) {-- const cmds = ['/usr/bin/systemctl restart openvibe-network', '/usr/bin/systemctl restart openvibe-live'];-- if (isWeb) cmds.push('/usr/sbin/nginx -t', '/usr/bin/systemctl reload nginx', '/usr/bin/systemctl restart nginx', '/usr/bin/systemctl reload php*-fpm', '/usr/bin/systemctl restart php*-fpm');-- steps.push(`Allow ONLY these exact root commands via sudo (nothing else):\n echo '${user} ALL=(root) NOPASSWD: ${cmds.join(', ')}' | sudo tee /etc/sudoers.d/${user}\n sudo chmod 440 /etc/sudoers.d/${user}\n sudo visudo -c`);-- }-- steps.push(`Verify:\n sudo -u ${user} whoami\n sudo -u ${user} ls -la ${firstRoot}${isWeb ? '\n sudo -u ' + user + ' test -w /etc/nginx/sites-enabled && echo "nginx: writable"' : ''}`);-- const numbered = steps.map((s, i) => `${i + 1}. ${s}`).join('\n\n');---- const purpose = isWeb-- ? 'help develop our sites AND build/run their own nginx + PHP sites on the box'-- : 'help develop the sites';-- const webNote = isWeb-- ? '\n\nNote: this tier gives them full control of the web layer (nginx configs + a /srv/www web root + reloading nginx/php-fpm) but still NOT blanket root — installing new system packages (PHP runtime, etc.) stays an owner action. If PHP-FPM is not installed yet, install it once before they start.'-- : '';---- const prompt =--`You are provisioning a locked-down SSH account on my Linux server for a trusted OpenVibe admin so they can SSH in and ${purpose} (vibe coding with Claude Code). Be careful and idempotent, and do NOT grant this user root/sudo beyond exactly what is specified below.----Server host: ${host}--New username: ${user}--Their SSH public key:--${pubShown}----Steps:--${numbered}${webNote}----Finally, tell me the exact command they should run to connect:-- ssh ${user}@${host}`;-- _sshSetText('ssh-o-prompt', prompt);--}----// ── Admin (user) view ───────────────────────────────────────--function renderSSHUser(el) {-- const defUser = _sshSanitizeUser(currentUser.username) || 'me';-- el.innerHTML = `-- <div class="card">-- <h3><i class="fa-solid fa-terminal"></i> Connect to the dev server <span class="staff-pill user-pill">You</span></h3>-- <p class="admin-intro">This page explains how to sign in to the development server. Use it when you need server access; your private key never leaves your machine.</p>-- <p class="muted">Generate your key, send the owner (<b>${esc(sshInfo.ownerUsername)}</b>) your <b>public</b> key to authorize, then SSH in and help build the sites with Claude. Your private key never leaves your machine.</p>-- </div>-- <div class="card">-- <div class="form-group">-- <label>Your SSH username <span class="muted">— confirm the exact name with the owner</span></label>-- <input type="text" id="ssh-u-user" aria-label="Your server username" aria-describedby="help-ssh-u-user" value="${esc(defUser)}" oninput="sshUserUpdate()" style="max-width:280px"><span class="admin-help" id="help-ssh-u-user">Starts with your account username when available. Confirm the actual server login with the owner, for example alex.</span>-- </div>-- </div>-- <div class="card">-- <h3><i class="fa-solid fa-list-ol"></i> Steps</h3>-- <p class="admin-intro">These are the commands that set up your key and the connection to the server. Run them once, then use the connection line to sign in.</p>-- <ol class="muted" style="line-height:1.9;padding-left:20px;margin:0">-- <li>Generate a key pair (skip if you already have one you want to use):<br><code id="ssh-u-keygen"></code></li>-- <li>Print your <b>public</b> key and send it to the owner to authorize:<br><code id="ssh-u-cat"></code></li>-- <li>Once they confirm it's added, put this in your <code>~/.ssh/config</code>:<pre id="ssh-u-config" class="ssh-prompt" style="margin-top:6px"></pre></li>-- <li>Connect: <code id="ssh-u-connect"></code></li>-- </ol>-- </div>-- <div class="card">-- <div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">-- <h3 style="margin:0"><i class="fa-solid fa-robot"></i> Agent prompt</h3>-- <button class="btn btn-primary" onclick="sshCopy('ssh-u-prompt', this)"><i class="fa-solid fa-copy"></i> Copy the prompt</button>-- </div>-- <p class="muted" style="margin:8px 0">Paste into Claude Code on your own machine — it sets up the connection and gives it full context about the server so you can start vibe coding.</p>-- <pre id="ssh-u-prompt" class="ssh-prompt"></pre>-- </div>`;-- sshUserUpdate();--}----function sshUserUpdate() {-- const host = sshInfo.host || '<server-host>';-- const user = _sshSanitizeUser(document.getElementById('ssh-u-user')?.value) || '<username>';-- const keygen = `ssh-keygen -t ed25519 -C "${user}@openvibe" -f ~/.ssh/openvibe_${user}`;-- const cat = `cat ~/.ssh/openvibe_${user}.pub`;-- const config = `Host openvibe\n HostName ${host}\n User ${user}\n IdentityFile ~/.ssh/openvibe_${user}\n ServerAliveInterval 60`;-- const connect = `ssh openvibe`;-- _sshSetText('ssh-u-keygen', keygen);-- _sshSetText('ssh-u-cat', cat);-- _sshSetText('ssh-u-config', config);-- _sshSetText('ssh-u-connect', connect);---- const prompt =--`Help me connect to the OpenVibe dev server over SSH, then help me develop the sites (vibe coding with Claude Code).----My SSH username: ${user}--Server host: ${host}----Do this:--1. If I don't already have this key, generate one (leave the passphrase empty, or set one — my call):-- ${keygen}--2. Print my PUBLIC key so I can send it to the server owner (${sshInfo.ownerUsername}) to authorize. Nothing works until they add it:-- ${cat}--3. Add this block to my ~/.ssh/config (create the file if needed, then chmod 600 ~/.ssh/config):--${config}--4. Test the connection:-- ${connect}----Context about the server so you can help me once I'm connected:--- Projects live under: ${sshInfo.projectsRoot}-- (openvibe.network under /opt/openvibe.network, OpenVibe.Live under /opt/openvibe.live)--- Services are systemd units: ${sshInfo.services.join(', ')}. Deploys are git-based —-- cd into the project, git pull --ff-only, then restart the service (if I'm permitted).--- I'm a OpenVibe admin, not root. nginx / TLS / packages / system-level changes go through the owner.--- Other devs share the project group, so be careful with shared files and always pull before editing.--Once I'm connected, cd into the relevant project and let's build.`;-- _sshSetText('ssh-u-prompt', prompt);--}----// ═══════════════════════════════════════════════════════════════--// Deploy Panel — TLS / Nginx / Infrastructure Control Plane--// ═══════════════════════════════════════════════════════════════----function showDeploySubTab(tab, btn) {-- deployCurrentSubTab = tab;-- document.querySelectorAll('#deploy-sub-tabs button').forEach(b => b.classList.remove('active'));-- (btn || _subBtn('deploy-sub-tabs', tab))?.classList.add('active');-- ['deploy-overview', 'deploy-certs-panel', 'deploy-nginx-panel', 'deploy-domains-panel', 'deploy-config-panel'].forEach(id => {-- const el = document.getElementById(id);-- if (el) el.style.display = 'none';-- });-- const map = {-- overview: 'deploy-overview',-- certs: 'deploy-certs-panel',-- nginx: 'deploy-nginx-panel',-- domains: 'deploy-domains-panel',-- config: 'deploy-config-panel',-- };-- const target = document.getElementById(map[tab]);-- if (target) target.style.display = 'block';-- if (tab === 'overview') loadDeployOverview();-- if (tab === 'certs') loadDeployCerts();-- if (tab === 'domains') loadDeployDomains();-- if (tab === 'config') loadDeployConfig();-- _syncAdminUrl();--}----async function loadDeployOverview() {-- try {-- const r = await fetch(`${API}/api/admin/deploy/prerequisites`, { headers: authHeaders() });-- const d = await r.json();-- if (!d.ok) throw new Error(d.error);-- const p = d.prerequisites;-- const c = d.config;-- document.getElementById('deploy-stats').innerHTML = `-- <div class="stat-card"><div class="value" style="color:${p.certbotInstalled ? 'var(--success)' : 'var(--danger)'}"><i class="fa-solid fa-${p.certbotInstalled ? 'check' : 'xmark'}"></i></div><div class="label">Certbot</div></div>-- <div class="stat-card"><div class="value" style="color:${p.cloudflarePluginInstalled ? 'var(--success)' : 'var(--warning)'}"><i class="fa-solid fa-${p.cloudflarePluginInstalled ? 'check' : 'minus'}"></i></div><div class="label">Cloudflare certificate plugin</div></div>-- <div class="stat-card"><div class="value" style="color:${p.nginxInstalled ? 'var(--success)' : 'var(--danger)'}"><i class="fa-solid fa-${p.nginxInstalled ? 'check' : 'xmark'}"></i></div><div class="label">Nginx</div></div>-- <div class="stat-card"><div class="value" style="color:${c.hasCloudflareToken ? 'var(--success)' : 'var(--text-muted)'}"><i class="fa-solid fa-${c.hasCloudflareToken ? 'check' : 'minus'}"></i></div><div class="label">Cloudflare access token</div></div>-- <div class="stat-card"><div class="value">${c.domainsConfigured}</div><div class="label">Domains</div></div>-- <div class="stat-card"><div class="value" style="font-size:14px">${esc({cloudflare:'Automatic DNS verification',manual:'Manual DNS verification',none:'Certificates managed elsewhere'}[c.certMode] || 'Certificate mode unavailable')}</div><div class="label">Certificate management</div></div>-- <div class="stat-card"><div class="value" style="font-size:14px">${esc({preview:'Preview only; no files written',apply:'Writing and reloading allowed',disabled:'Web server management disabled'}[c.nginxMode] || 'Web server mode unavailable')}</div><div class="label">Web server management</div></div>-- `;-- const prereqsEl = document.getElementById('deploy-prereqs');-- const items = [];-- items.push(prereqLine('Certbot installed', p.certbotInstalled));-- items.push(prereqLine('Cloudflare DNS plugin', p.cloudflarePluginInstalled, 'Optional — needed for automated DNS-01'));-- items.push(prereqLine('Let\'s Encrypt dir writable', p.letsencryptDirWritable, 'Run as root or with sudo'));-- items.push(prereqLine('Nginx installed', p.nginxInstalled));-- items.push(prereqLine('Cloudflare INI exists', p.cloudflareIniExists, 'Auto-created when token is saved'));-- items.push(prereqLine('ACME email configured', !!c.acmeEmail, c.acmeEmail || 'Not set'));-- prereqsEl.innerHTML = items.join('');-- } catch (e) {-- document.getElementById('deploy-stats').innerHTML = `<div class="muted" style="color:var(--danger-text, var(--danger))">${esc(e.message)}</div>`;-- }--}----function prereqLine(label, ok, detail) {-- const icon = ok ? '<i class="fa-solid fa-circle-check" style="color:var(--success-text, var(--success))"></i>' : '<i class="fa-solid fa-circle-xmark" style="color:var(--danger-text, var(--danger))"></i>';-- const detailHtml = detail ? ` <span class="muted" style="font-size:11px">— ${esc(detail)}</span>` : '';-- return `<div style="padding:6px 0;display:flex;align-items:center;gap:8px">${icon} ${esc(label)}${detailHtml}</div>`;--}----async function loadDeployCerts() {-- const el = document.getElementById('deploy-certs-list');-- try {-- const r = await fetch(`${API}/api/admin/deploy/certs`, { headers: authHeaders() });-- const d = await r.json();-- if (!d.ok && d.error) throw new Error(d.error);-- if (!d.certs || d.certs.length === 0) {-- el.innerHTML = '<div class="muted">No certificates found. Issue your first certificate below.</div>';-- return;-- }-- el.innerHTML = d.certs.map(c => {-- const isWild = c.domains?.some(d => d.startsWith('*.'));-- const expColor = (c.daysRemaining || 0) < 14 ? 'var(--danger)' : (c.daysRemaining || 0) < 30 ? 'var(--warning)' : 'var(--success)';-- return `<div class="card" style="margin-bottom:8px;padding:12px">-- <div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px">-- <div>-- <strong>${esc(c.name)}</strong>-- ${isWild ? '<span style="font-size:10px;background:var(--accent);color:#000;padding:1px 6px;border-radius:3px;margin-left:6px">WILDCARD</span>' : ''}-- <div class="muted" style="font-size:11px;margin-top:2px">${(c.domains || []).map(d => esc(d)).join(', ')}</div>-- </div>-- <div style="text-align:right">-- <span style="color:${expColor};font-weight:600">${c.daysRemaining != null ? 'Expires in ' + c.daysRemaining + ' days.' : 'Expiry date is unknown.'}</span>-- <div class="muted" style="font-size:11px">${c.expiry ? 'Expires ' + esc(c.expiry) : ''}</div>-- </div>-- </div>-- </div>`;-- }).join('');-- } catch (e) {-- el.innerHTML = `<div class="muted" style="color:var(--danger-text, var(--danger))">${esc(e.message)}</div>`;-- }--}----async function issueCertCloudflare() {-- const domain = document.getElementById('cert-issue-domain').value.trim();-- if (!domain) return alert('Enter a domain');-- const out = document.getElementById('cert-issue-output');-- out.innerHTML = '<div class="loading">Issuing wildcard certificate via Cloudflare DNS... This may take 30-60 seconds.</div>';-- try {-- const r = await fetch(`${API}/api/admin/deploy/certs/issue-cloudflare`, {-- method: 'POST', headers: { ...authHeaders(), 'Content-Type': 'application/json' },-- body: JSON.stringify({ domain }),-- });-- const d = await r.json();-- if (d.ok) {-- out.innerHTML = `<div style="color:var(--success-text, var(--success));padding:8px"><i class="fa-solid fa-check-circle"></i> Certificate issued: ${esc(d.certName || domain)}</div>-- <pre style="background:var(--bg-input);padding:8px;border-radius:6px;font-size:11px;max-height:200px;overflow:auto;margin-top:8px">${esc(d.output || '')}</pre>`;-- loadDeployCerts();-- } else {-- out.innerHTML = `<div style="color:var(--danger-text, var(--danger));padding:8px"><i class="fa-solid fa-xmark"></i> ${esc(d.error || 'Failed')}</div>-- <pre style="background:var(--bg-input);padding:8px;border-radius:6px;font-size:11px;max-height:200px;overflow:auto;margin-top:8px">${esc(d.output || '')}</pre>`;-- }-- } catch (e) {-- out.innerHTML = `<div style="color:var(--danger-text, var(--danger))">${esc(e.message)}</div>`;-- }--}----async function showManualDnsInfo() {-- const domain = document.getElementById('cert-issue-domain').value.trim();-- if (!domain) return alert('Enter a domain');-- const out = document.getElementById('cert-issue-output');-- out.innerHTML = '<div class="loading">Getting manual DNS challenge info...</div>';-- try {-- const r = await fetch(`${API}/api/admin/deploy/certs/manual-info`, {-- method: 'POST', headers: { ...authHeaders(), 'Content-Type': 'application/json' },-- body: JSON.stringify({ domain }),-- });-- const d = await r.json();-- const instructions = (d.instructions || []).map(i => esc(i)).join('<br>');-- out.innerHTML = `<div class="card" style="padding:16px;margin-top:8px">-- <h4 style="margin-bottom:8px"><i class="fa-solid fa-hand"></i> Manual DNS challenge</h4>-- <p class="admin-intro">These are the DNS records to add at your domain registrar so the certificate can be issued by hand. Add them exactly as written, then issue the certificate; the request fails until the records are visible.</p>-- <div style="font-size:13px;line-height:1.6">${instructions}</div>-- <div style="margin-top:16px">-- <button class="btn btn-primary" onclick="issueManualCert('${esc(domain)}')">-- <i class="fa-solid fa-ranking-star"></i> DNS Records Created — Issue Certificate-- </button>-- <div class="muted" style="font-size:11px;margin-top:4px">Only click after creating the required DNS TXT records</div>-- </div>-- </div>`;-- } catch (e) {-- out.innerHTML = `<div style="color:var(--danger-text, var(--danger))">${esc(e.message)}</div>`;-- }--}----async function issueManualCert(domain) {-- const out = document.getElementById('cert-issue-output');-- out.innerHTML = '<div class="loading">Issuing certificate with manual DNS verification... This may take 30-60 seconds.</div>';-- try {-- const r = await fetch(`${API}/api/admin/deploy/certs/issue-manual`, {-- method: 'POST', headers: { ...authHeaders(), 'Content-Type': 'application/json' },-- body: JSON.stringify({ domain }),-- });-- const d = await r.json();-- if (d.ok) {-- out.innerHTML = `<div style="color:var(--success-text, var(--success));padding:8px"><i class="fa-solid fa-check-circle"></i> Certificate issued: ${esc(d.certName || domain)}</div>-- <pre style="background:var(--bg-input);padding:8px;border-radius:6px;font-size:11px;max-height:200px;overflow:auto;margin-top:8px">${esc(d.output || '')}</pre>`;-- loadDeployCerts();-- } else {-- out.innerHTML = `<div style="color:var(--danger-text, var(--danger));padding:8px"><i class="fa-solid fa-xmark"></i> ${esc(d.error || 'Failed')}</div>-- <pre style="background:var(--bg-input);padding:8px;border-radius:6px;font-size:11px;max-height:200px;overflow:auto;margin-top:8px">${esc(d.output || '')}</pre>`;-- }-- } catch (e) {-- out.innerHTML = `<div style="color:var(--danger-text, var(--danger))">${esc(e.message)}</div>`;-- }--}----async function renewAllCerts() {-- const out = document.getElementById('cert-issue-output');-- out.innerHTML = '<div class="loading">Renewing all certificates...</div>';-- try {-- const r = await fetch(`${API}/api/admin/deploy/certs/renew`, {-- method: 'POST', headers: authHeaders(),-- });-- const d = await r.json();-- out.innerHTML = `<div style="color:${d.ok ? 'var(--success)' : 'var(--danger)'};padding:8px">-- <i class="fa-solid fa-${d.ok ? 'check-circle' : 'xmark'}"></i> ${d.ok ? 'Renewal complete' : esc(d.error || 'Failed')}-- </div>-- <pre style="background:var(--bg-input);padding:8px;border-radius:6px;font-size:11px;max-height:200px;overflow:auto;margin-top:8px">${esc(d.output || '')}</pre>`;-- if (d.ok) loadDeployCerts();-- } catch (e) {-- out.innerHTML = `<div style="color:var(--danger-text, var(--danger))">${esc(e.message)}</div>`;-- }--}----async function previewNginxConfigs() {-- const out = document.getElementById('nginx-preview-output');-- out.innerHTML = '<div class="loading">Generating Nginx configs...</div>';-- try {-- const r = await fetch(`${API}/api/admin/deploy/nginx/preview`, { headers: authHeaders() });-- const d = await r.json();-- if (!d.ok) throw new Error(d.error);-- out.innerHTML = d.configs.map(c => `-- <div class="card" style="margin-bottom:12px">-- <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">-- <h4>${esc(c.serviceId)} <span class="muted" style="font-size:11px">${esc(c.filename)}</span></h4>-- <span style="font-size:11px;color:${c.certFound ? 'var(--success)' : 'var(--warning)'}">${c.certFound ? '✓ cert found' : '⚠ no cert'}</span>-- </div>-- <pre style="background:var(--bg-input);padding:12px;border-radius:6px;font-size:11px;max-height:300px;overflow:auto;white-space:pre-wrap">${esc(c.content)}</pre>-- </div>-- `).join('');-- } catch (e) {-- out.innerHTML = `<div style="color:var(--danger-text, var(--danger))">${esc(e.message)}</div>`;-- }--}----async function validateNginx() {-- const out = document.getElementById('nginx-preview-output');-- out.innerHTML = '<div class="loading">Validating Nginx config...</div>';-- try {-- const r = await fetch(`${API}/api/admin/deploy/nginx/validate`, {-- method: 'POST', headers: authHeaders(),-- });-- const d = await r.json();-- out.innerHTML = `<div class="card" style="padding:16px">-- <div style="color:${d.ok ? 'var(--success)' : 'var(--danger)'}">-- <i class="fa-solid fa-${d.ok ? 'check-circle' : 'times-circle'}"></i>-- <strong>${d.ok ? 'Configuration valid' : 'Validation failed'}</strong>-- </div>-- <pre style="background:var(--bg-input);padding:8px;border-radius:6px;font-size:11px;margin-top:8px;max-height:200px;overflow:auto">${esc(d.output || '')}</pre>-- </div>`;-- } catch (e) {-- out.innerHTML = `<div style="color:var(--danger-text, var(--danger))">${esc(e.message)}</div>`;-- }--}----async function applyNginxConfigs() {-- if (!confirm('Apply the generated web server configuration? This backs up the existing files, writes new ones, validates them, and reloads Nginx. Routing may change immediately. To undo, restore the backup on the server, validate it, and reload Nginx.')) return;-- const out = document.getElementById('nginx-preview-output');-- out.innerHTML = '<div class="loading">Applying Nginx configs...</div>';-- try {-- const r = await fetch(`${API}/api/admin/deploy/nginx/apply`, {-- method: 'POST', headers: { ...authHeaders(), 'Content-Type': 'application/json' },-- body: JSON.stringify({ reload: true }),-- });-- const d = await r.json();-- const lines = [];-- lines.push(`<div style="color:${d.ok ? 'var(--success)' : 'var(--danger)'}"><i class="fa-solid fa-${d.ok ? 'check-circle' : 'times-circle'}"></i> <strong>${d.ok ? 'Applied successfully' : 'Apply failed'}</strong></div>`);-- if (d.dryRun) lines.push('<div class="muted">Dry run — no files were written</div>');-- if (d.applied?.length) lines.push(`<div>Applied: ${d.applied.map(f => esc(f)).join(', ')}</div>`);-- if (d.backed_up?.length) lines.push(`<div class="muted">Backed up: ${d.backed_up.length} files</div>`);-- if (d.validation) lines.push(`<div>Validation: ${d.validation.ok ? '✓ passed' : '✗ failed'}</div>`);-- if (d.reload) lines.push(`<div>Reload: ${d.reload.ok ? '✓ success' : '✗ failed'}</div>`);-- if (d.errors?.length) lines.push(`<div style="color:var(--danger-text, var(--danger))">Errors: ${d.errors.map(e => esc(e)).join('; ')}</div>`);-- out.innerHTML = `<div class="card" style="padding:16px">${lines.join('')}</div>`;-- } catch (e) {-- out.innerHTML = `<div style="color:var(--danger-text, var(--danger))">${esc(e.message)}</div>`;-- }--}----let deployDomainsList = [];----async function loadDeployDomains() {-- const el = document.getElementById('deploy-domains-list');-- try {-- const r = await fetch(`${API}/api/admin/deploy/domains`, { headers: authHeaders() });-- const d = await r.json();-- if (!d.ok) throw new Error(d.error);-- deployDomainsList = d.domains || [];-- if (deployDomainsList.length === 0) {-- el.innerHTML = '<div class="muted">No domains configured. Add your first domain below.</div>';-- return;-- }-- el.innerHTML = deployDomainsList.map((d, i) => `-- <div class="card" style="margin-bottom:6px;padding:10px;display:flex;justify-content:space-between;align-items:center">-- <div>-- <strong>${esc(d.domain)}</strong>-- ${d.wildcard ? '<span style="font-size:10px;background:var(--accent);color:#000;padding:1px 6px;border-radius:3px;margin-left:6px">WILDCARD</span>' : ''}-- <span class="muted" style="font-size:11px;margin-left:8px">${d.certExists ? '🔒 cert found' : '⚠ no cert'}</span>-- ${d.certName ? '<span class="muted" style="font-size:11px;margin-left:4px">(' + esc(d.certName) + ')</span>' : ''}-- </div>-- <button class="btn btn-danger btn-sm" onclick="removeManagedDomain(${i})" title="Remove">-- <i class="fa-solid fa-trash"></i>-- </button>-- </div>-- `).join('');-- } catch (e) {-- el.innerHTML = `<div class="muted" style="color:var(--danger-text, var(--danger))">${esc(e.message)}</div>`;-- }--}----async function addManagedDomain() {-- const domain = document.getElementById('domain-add-input').value.trim().toLowerCase();-- if (!domain) return;-- if (!/^[a-z0-9.-]+\.[a-z]{2,}$/.test(domain)) return alert('Invalid domain format');-- if (deployDomainsList.find(d => d.domain === domain)) return alert('Domain already added');-- const wildcard = document.getElementById('domain-add-wildcard').checked;-- deployDomainsList.push({ domain, wildcard, certName: domain, services: [] });-- await saveDomainsList();-- document.getElementById('domain-add-input').value = '';--}----async function removeManagedDomain(idx) {-- if (!confirm('Remove domain ' + deployDomainsList[idx]?.domain + '? It will leave the managed domain list. Add it again to undo; existing certificates and web server files are managed separately.')) return;-- deployDomainsList.splice(idx, 1);-- await saveDomainsList();--}----async function saveDomainsList() {-- try {-- const r = await fetch(`${API}/api/admin/deploy/domains`, {-- method: 'PUT', headers: { ...authHeaders(), 'Content-Type': 'application/json' },-- body: JSON.stringify({ domains: deployDomainsList }),-- });-- const d = await r.json();-- if (!d.ok) throw new Error(d.error);-- loadDeployDomains();-- } catch (e) {-- alert('Failed to save: ' + e.message);-- }--}----async function loadDeployConfig() {-- try {-- const r = await fetch(`${API}/api/admin/deploy/config`, { headers: authHeaders() });-- const d = await r.json();-- if (!d.ok) throw new Error(d.error);-- document.getElementById('deploy-acme-email').value = d.config.acmeEmail || '';-- document.getElementById('deploy-cert-mode').value = d.config.certMode || 'manual';-- document.getElementById('deploy-cf-token').value = d.config.cloudflareToken || '';-- document.getElementById('deploy-nginx-mode').value = d.config.nginxMode || 'preview';-- document.getElementById('deploy-nginx-path').value = d.config.nginxSitesPath || '/etc/nginx/sites-enabled';-- } catch (e) {-- document.getElementById('deploy-config-status').innerHTML = `<span style="color:var(--danger-text, var(--danger))">${esc(e.message)}</span>`;-- }--}----async function saveDeployConfig() {-- const status = document.getElementById('deploy-config-status');-- try {-- const body = {-- acmeEmail: document.getElementById('deploy-acme-email').value.trim(),-- certMode: document.getElementById('deploy-cert-mode').value,-- cloudflareToken: document.getElementById('deploy-cf-token').value,-- nginxMode: document.getElementById('deploy-nginx-mode').value,-- nginxSitesPath: document.getElementById('deploy-nginx-path').value.trim(),-- };-- const r = await fetch(`${API}/api/admin/deploy/config`, {-- method: 'PUT', headers: { ...authHeaders(), 'Content-Type': 'application/json' },-- body: JSON.stringify(body),-- });-- const d = await r.json();-- if (!d.ok) throw new Error(d.error);-- status.innerHTML = '<span style="color:var(--success-text, var(--success))"><i class="fa-solid fa-check"></i> Saved</span>';-- setTimeout(() => status.innerHTML = '', 3000);-- } catch (e) {-- status.innerHTML = `<span style="color:var(--danger-text, var(--danger))">${esc(e.message)}</span>`;-- }--}@@@ -28,7 +28,7 @@ function glossaryEntry(id) {/** The small "What does this mean?" link next to a word. Opens the line in place; a new tab opens the full list. */function glossaryLink(id, label) {if (!glossaryEntry(id)) return '';-- return '<a class="ov-what" href="/admin#glossary/' + esc(id) + '" data-glossary="' + esc(id) + '">' + esc(label || 'What does this mean?') + '</a>';++ return '<a class="ov-what" href="#" role="button" data-glossary="' + esc(id) + '">' + esc(label || 'What does this mean?') + '</a>';}function glossaryPopup(id, anchor) {@@@ -45,17 +45,12 @@ function glossaryPopup(id, anchor) {p.textContent = entry.text;const actions = document.createElement('div');actions.className = 'pai-dialog-actions';-- const all = document.createElement('a');-- all.className = 'btn btn-outline';-- all.href = '/admin#glossary/' + entry.id;-- all.textContent = 'Every word explained';-- all.addEventListener('click', (ev) => { ev.preventDefault(); dlg.close(); openGlossary(entry.id); });const close = document.createElement('button');close.type = 'button';close.className = 'btn btn-primary';close.textContent = 'Close';close.addEventListener('click', () => dlg.close());-- actions.append(all, close);++ actions.append(close);form.append(h, p, actions);dlg.append(form);document.body.appendChild(dlg);@@@ -64,20 +59,6 @@ function glossaryPopup(id, anchor) {}/** Scroll the list to one word and mark it for a moment (the list must already be drawn). */--function scrollGlossary(id) {-- if (!id) return;-- setTimeout(() => {-- const el = document.getElementById('glossary-' + id);-- if (el) { el.scrollIntoView({ block: 'center' }); el.classList.add('is-target'); setTimeout(() => el.classList.remove('is-target'), 1600); }-- }, 60);--}----/** Open the Glossary tab (which draws the list) at one word. */--function openGlossary(id) {-- if (typeof showTab === 'function') showTab('glossary');-- scrollGlossary(id);--}--// One listener for every "What does this mean?" link on the page, wherever it was drawn.document.addEventListener('click', (ev) => {const link = ev.target.closest && ev.target.closest('a[data-glossary]');@@@ -85,15 +66,3 @@ document.addEventListener('click', (ev) => {ev.preventDefault();glossaryPopup(link.dataset.glossary, link);});----function loadGlossary() {-- const box = document.getElementById('glossary-content');-- if (!box) return;-- box.innerHTML = '<div class="glossary">'-- + '<h2>What the words mean</h2>'-- + '<p class="muted glossary-intro">The few words the admin panel cannot avoid, in plain words. A "What does this mean?" link next to a word opens its line from here.</p>'-- + '<dl class="glossary-list">' + GLOSSARY.map((g) => '<div class="glossary-item" id="glossary-' + esc(g.id) + '"><dt>' + esc(g.word) + '</dt><dd>' + esc(g.text) + '</dd></div>').join('') + '</dl></div>';-- // /admin#glossary/<word> opens the tab at that word (the tab itself was opened by the URL routing).-- const target = (location.hash.match(/^#glossary\/([a-z-]+)/) || [])[1];-- if (target) scrollGlossary(target);--}@@@ -1,5 +1,5 @@/* ═══════════════════════════════════════════════════════════════-- OpenVibe.Network — admin panel (/admin): payment settings, the Billing pointer and verification keys.++ OpenVibe.Network — admin panel (/admin): payment settings and the Billing pointer.Split out of admin.html. Classic scripts, global scope: they rely onadmin/core.js's helpers (api, esc, toast, API, token) and on thetabLoaders map in admin/boot.js (loaded last). No ES modules.@@@ -97,77 +97,3 @@ async function savePayments() {// Cashouts: the tab is a pointer to OpenVibe.Billing's console (admin.html #sec-cashouts); Live's old cashout queue// (/api/funds) answers 409 since the Billing cutover.----// ═══════════════════════════════════════════════════════════════--// Verification Keys--// ═══════════════════════════════════════════════════════════════--const secretStore = {};--async function loadVKeys() {-- const c = document.getElementById('vkeys-content');-- c.innerHTML = '<div class="loading">Loading...</div>';-- try {-- const data = await api('/api/admin/verification-keys');-- const keys = data.keys || [];-- keys.forEach(k => { if (k.key) secretStore[`vk-${k.id}`] = k.key; });-- if (!keys.length) { c.innerHTML = '<p class="muted">No account reservation keys yet. Enter a username and choose Generate to reserve it.</p>'; return; }-- c.innerHTML = `-- <table class="admin-table">-- <thead><tr><th>Key</th><th>Reserved Username</th><th>Status</th><th>Note</th><th>Created</th><th>Actions</th></tr></thead>-- <tbody>${keys.map(k => `-- <tr>-- <td><code id="vk-${k.id}" style="font-size:12px;color:var(--text-muted)">••••••••</code></td>-- <td><strong>${esc(k.target_username)}</strong></td>-- <td><span class="badge badge-${k.status==='active'?'success':k.status==='used'?'info':'danger'}">${esc({ active: 'Ready to claim.', used: 'Already claimed.', revoked: 'No longer usable.' }[k.status] || 'Status unavailable.')}</span></td>-- <td style="font-size:12px">${esc(k.note || '-')}</td>-- <td style="font-size:11px">${k.created_at ? new Date(k.created_at).toLocaleDateString() : '-'}</td>-- <td style="display:flex;gap:4px">-- ${k.status === 'active' ? `-- <button class="btn btn-sm btn-outline" aria-label="Show or hide reservation key" onclick="revealVKey('vk-${k.id}')"><i class="fa-solid fa-eye"></i></button>-- <button class="btn btn-sm btn-outline" aria-label="Copy reservation key" onclick="copyVKey('vk-${k.id}')"><i class="fa-solid fa-copy"></i></button>-- <button class="btn btn-sm btn-danger" title="Revoke permanently; generate a new key to replace it" aria-label="Revoke reservation key" onclick="revokeVKey('${k.id}')"><i class="fa-solid fa-trash"></i></button>-- ` : k.status === 'used' ? `<span class="muted">Used by ${esc(k.used_by_name || '?')}</span>` : '<span class="muted">Revoked</span>'}-- </td>-- </tr>-- `).join('')}</tbody>-- </table>`;-- } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }--}----function revealVKey(id) {-- const el = document.getElementById(id);-- if (!el) return;-- const revealed = el.dataset.revealed === 'true';-- el.textContent = revealed ? '••••••••' : (secretStore[id] || '••••••••');-- el.dataset.revealed = revealed ? 'false' : 'true';-- el.style.color = revealed ? 'var(--text-muted)' : 'var(--accent-light)';--}----function copyVKey(id) {-- const val = secretStore[id];-- if (!val) return toast('No key to copy', 'error');-- navigator.clipboard.writeText(val).then(() => toast('Key copied', 'success')).catch(() => toast('Copy failed', 'error'));--}----async function generateVKey() {-- const username = document.getElementById('vkey-username')?.value.trim();-- const note = document.getElementById('vkey-note')?.value.trim();-- if (!username) return toast('Enter a username', 'error');-- try {-- const data = await api('/api/admin/verification-keys', { method: 'POST', body: { target_username: username, note } });-- const key = data.key;-- if (key?.id && key?.key) secretStore[`vk-${key.id}`] = key.key;-- toast('Verification key generated', 'success');-- document.getElementById('vkey-username').value = '';-- document.getElementById('vkey-note').value = '';-- loadVKeys();-- } catch (e) { toast(e.message, 'error'); }--}----async function revokeVKey(id) {-- if (!confirm('Revoke this account reservation key? It will no longer work. This cannot be undone; generate a new key if access is needed again.')) return;-- try {-- await api(`/api/admin/verification-keys/${id}`, { method: 'DELETE' });-- toast('Key revoked', 'success');-- loadVKeys();-- } catch (e) { toast(e.message, 'error'); }--}@@@ -868,16 +868,9 @@ async function handleRegister(e) {username: form.get('username'),email: form.get('email') || undefined,password: form.get('password'),-- verification_key: form.get('verification_key') || undefined,}),});const data = await res.json();-- // A reserved name: show the claim-code field only now (most people never need it).-- if (!res.ok && data.reserved) {-- const claim = document.getElementById('claim-field');-- if (claim) { claim.hidden = false; document.getElementById('claim-code').focus(); }-- throw new Error('This username is reserved. Enter its claim code, or choose another name.');-- }if (!res.ok) throw new Error(data.error || 'Registration failed');localStorage.setItem('ov_token', data.token);@@@ -125,11 +125,6 @@<label>Email <span style="color:var(--text-dim)">(optional)</span></label><input type="email" name="email" placeholder="[email protected]" autocomplete="email"></div>-- <div class="field" id="claim-field" hidden>-- <label for="claim-code">Claim code</label>-- <input type="text" id="claim-code" name="verification_key" placeholder="OPENVIBE-XXXX-XXXX-XXXX" style="font-family:monospace" autocomplete="off">-- <small class="panel-note">This name is reserved for someone. If it is yours, enter the code you were given to claim it; otherwise choose another name.</small>-- </div><div class="field"><label>Password</label><input type="password" name="password" placeholder="Min 6 characters" required minlength="6" autocomplete="new-password">@@@ -50,7 +50,7 @@ const AREAS = [{ area: 'Releases and readiness', items: [{ item: 'Migration and readiness', where: [{ kind: 'url', url: 'https://openvibe.network/status' }, { kind: 'cli', cli: 'ovhost status, ovhost validate <service>' }], service: 'host' },{ item: 'Compatibility status (contracts, pins, the register)', where: [{ kind: 'url', url: 'https://openvibe.services/docs' }, { kind: 'cli', cli: 'ovhost validate <service> (contracts range)' }], service: 'codes' },-- { item: 'Deploys', where: [{ kind: 'tab', tab: 'deploy' }, { kind: 'cli', cli: 'ovhost deploy <service>' }], service: 'host' },++ { item: 'Deploys', where: [{ kind: 'cli', cli: 'ovhost deploy <service>' }], service: 'host' },{ item: 'Domains and certificates', where: [{ kind: 'tab', tab: 'domains' }, { kind: 'cli', cli: 'ovhost certs' }], service: 'host' },] },];@@@ -912,84 +912,6 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {}});-- // ═══════════════════════════════════════════════════════-- // Verification Keys (reserved username claims)-- // ═══════════════════════════════════════════════════════---- router.get('/verification-keys', async (req, res) => {-- try {-- const keys = await db.getAllVerificationKeys();-- res.json({ ok: true, keys });-- } catch (err) {-- res.status(500).json({ ok: false, error: err.message });-- }-- });---- router.post('/verification-keys', async (req, res) => {-- try {-- const { target_username, note } = req.body;-- if (!target_username) {-- return res.status(400).json({ ok: false, error: 'Target username is required' });-- }-- if (!/^[a-zA-Z0-9_]+$/.test(target_username) || target_username.length < 3 || target_username.length > 24) {-- return res.status(400).json({ ok: false, error: 'Invalid username format (3-24 chars, alphanumeric + underscore)' });-- }---- // Check if username already taken-- const existingUser = await db.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?)').get(target_username);-- if (existingUser) {-- return res.status(409).json({ ok: false, error: `Username "${target_username}" is already registered` });-- }---- // Check for duplicate active key-- const existingKey = await db.getVerificationKeyByUsername(target_username);-- if (existingKey) {-- return res.status(409).json({ ok: false, error: `Active key already exists for "${target_username}"` });-- }---- // Generate OPENVIBE-XXXX-XXXX-XXXX-- const crypto = require('crypto');-- const key = 'OPENVIBE-' + [4, 4, 4].map(() =>-- crypto.randomBytes(2).toString('hex').toUpperCase()-- ).join('-');---- await db.createVerificationKey({-- key,-- target_username,-- note: note || '',-- created_by: req.user.id,-- });---- const created = await db.getVerificationKeyByKey(key);---- await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(-- req.user.id, 'verification_key_create', JSON.stringify({ key, target_username })-- );---- res.status(201).json({ ok: true, key: created });-- } catch (err) {-- console.error('[Admin] Verification key error:', err.message);-- res.status(500).json({ ok: false, error: 'Failed to generate key' });-- }-- });---- router.delete('/verification-keys/:id', async (req, res) => {-- try {-- const result = await db.revokeVerificationKey(req.params.id);-- if (result.changes === 0) {-- return res.status(404).json({ ok: false, error: 'Key not found or already used/revoked' });-- }---- await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(-- req.user.id, 'verification_key_revoke', JSON.stringify({ keyId: req.params.id })-- );---- res.json({ ok: true, message: 'Verification key revoked' });-- } catch (err) {-- res.status(500).json({ ok: false, error: 'Failed to revoke key' });-- }-- });--// ═══════════════════════════════════════════════════════// Net.OpenVibe — Network Tools Config// ═══════════════════════════════════════════════════════@@@ -124,7 +124,6 @@ router.post('/register', async (req, res) => {const username = String(req.body.username || '').trim();const password = String(req.body.password || '');const email = req.body.email ? String(req.body.email).trim() : undefined;-- const verification_key = req.body.verification_key ? String(req.body.verification_key).trim() : undefined;if (!username || !password) return res.status(400).json({ error: 'Username and password required' });if (username.length < 3 || username.length > 24) {@@@ -146,24 +145,6 @@ router.post('/register', async (req, res) => {// A name someone was renamed away from stays theirs for a while (links, mentions, reputation).if (await require('../identity/usernames').isReserved(db, username)) return res.status(409).json({ error: 'Username already taken' });-- // Check if username is reserved (has an active verification key)-- const reserved = await db.isUsernameReserved(username);-- if (reserved) {-- if (!verification_key) {-- return res.status(403).json({-- error: 'This username is reserved. A verification key is required to claim it.',-- reserved: true,-- });-- }-- const vk = await db.getVerificationKeyByKey(verification_key);-- if (!vk || vk.status !== 'active') {-- return res.status(403).json({ error: 'Invalid or expired verification key' });-- }-- if (vk.target_username.toLowerCase() !== username.toLowerCase()) {-- return res.status(403).json({ error: 'This verification key is for a different username' });-- }-- }--if (email) {const emailExists = await db.prepare('SELECT id FROM users WHERE LOWER(email) = LOWER(?)').get(email);if (emailExists) return res.status(409).json({ error: 'Email already in use' });@@@ -178,15 +159,10 @@ router.post('/register', async (req, res) => {// Create default preferencesawait db.prepare('INSERT INTO user_preferences (user_id) VALUES (?) ON CONFLICT DO NOTHING').run(result.lastInsertRowid);-- // Redeem verification key if used-- if (verification_key && reserved) {-- await db.redeemVerificationKey(verification_key, result.lastInsertRowid);-- }--const user = await db.prepare('SELECT * FROM users WHERE id = ?').get(result.lastInsertRowid);const token = signToken(user, req.app.locals.privateKey, config);-- console.log(`[Auth] New user registered: ${username} (id: ${user.id})${reserved ? ' [verification key redeemed]' : ''}`);++ console.log(`[Auth] New user registered: ${username} (id: ${user.id})`);// Confirm the address before it can receive opt-in mail (best-effort, rate-limited).if (user.email) { try { require('./email-verify').sendVerification(req, user).catch(() => {}); } catch { /* */ } }@@@ -463,37 +463,6 @@ function attachHelpers(db) {return row.value;};-- db.createVerificationKey = async function ({ key, target_username, note, created_by }) {-- return await db.prepare('INSERT INTO verification_keys (key, target_username, note, created_by) VALUES (?, ?, ?, ?)')-- .run(key, target_username, note || '', created_by);-- };-- db.getVerificationKeyByKey = async function (key) {-- return await db.prepare('SELECT * FROM verification_keys WHERE key = ?').get(key);-- };-- db.getVerificationKeyByUsername = async function (username) {-- return await db.prepare("SELECT * FROM verification_keys WHERE lower(target_username) = lower(?) AND status = 'active'").get(username);-- };-- db.getAllVerificationKeys = async function () {-- return await db.prepare(`-- SELECT vk.*, u1.username as created_by_name, u2.username as used_by_name-- FROM verification_keys vk-- LEFT JOIN users u1 ON vk.created_by = u1.id-- LEFT JOIN users u2 ON vk.used_by = u2.id-- ORDER BY vk.created_at DESC-- `).all();-- };-- db.redeemVerificationKey = async function (key, userId) {-- return await db.prepare("UPDATE verification_keys SET status = 'used', used_by = ?, used_at = ov_now() WHERE key = ? AND status = 'active'")-- .run(userId, key);-- };-- db.revokeVerificationKey = async function (id) {-- return await db.prepare("UPDATE verification_keys SET status = 'revoked' WHERE id = ? AND status = 'active'").run(id);-- };-- db.isUsernameReserved = async function (username) {-- const vk = await db.prepare("SELECT id FROM verification_keys WHERE lower(target_username) = lower(?) AND status = 'active'").get(username);-- return !!vk;-- };--Object.defineProperty(db, '_ovHelpers', { value: true, enumerable: false });return db;}ddeleted file mode 100644@@@ -1,818 +0,0 @@--'use strict';----// ═══════════════════════════════════════════════════════════════--// Nginx Config Generator — Template-driven vhost generation--// Replaces static hand-maintained nginx configs with generated--// configs derived from the canonical URL registry.--// ═══════════════════════════════════════════════════════════════----const { execFile } = require('child_process');--const fs = require('fs');--const path = require('path');--const { findBestCert } = require('./cert-manager');----// ── Default Service Definitions ──────────────────────────────--// These are the OpenVibe defaults. White-label installs override--// via DEPLOY_SERVICE_MAP and DEPLOY_DOMAINS in the registry.----const DEFAULT_SERVICE_MAP = {-- network: {-- port: 4000,-- domains: ['openvibe.network', 'my.openvibe.network'],-- maxBodySize: '5m',-- rateZones: [-- { name: 'openvibenetwork_api', rate: '10r/s' },-- { name: 'openvibenetwork_auth', rate: '5r/m' },-- { name: 'openvibenetwork_authorize', rate: '60r/m' },-- ],-- locations: [-- {-- // Every site's sign-in and silent check: no password here, so not the password endpoints' 5 a minute.-- match: '= /oauth/authorize',-- rateZone: 'openvibenetwork_authorize',-- rateBurst: 60,-- },-- {-- match: '~ ^/(api/auth/(register|login|anon-session)|oauth/token)',-- rateZone: 'openvibenetwork_auth',-- rateBurst: 5,-- },-- {-- // No cacheTime: the app sets Cache-Control on /shared/ (five minutes, a year for a-- // ?v= content-hash URL); expires/add_header here would send a second, conflicting one.-- match: '/shared/',-- },-- {-- match: '/api/',-- rateZone: 'openvibenetwork_api',-- rateBurst: 30,-- },-- {-- match: '/internal/',-- localhostOnly: true,-- },-- ],-- headers: {-- 'X-Content-Type-Options': 'nosniff',-- 'X-Frame-Options': 'SAMEORIGIN',-- 'Referrer-Policy': 'strict-origin-when-cross-origin',-- },-- },-- tools: {-- port: 4001,-- domains: ['openvibe.tools'],-- wildcardDomain: 'openvibe.tools',-- maxBodySize: '5m',-- rateZones: [-- { name: 'openvibetools_api', rate: '10r/s' },-- ],-- locations: [-- {-- match: '/api/',-- rateZone: 'openvibetools_api',-- rateBurst: 30,-- },-- ],-- headers: {-- 'X-Content-Type-Options': 'nosniff',-- 'X-Frame-Options': 'SAMEORIGIN',-- 'Referrer-Policy': 'strict-origin-when-cross-origin',-- },-- },-- media: {-- port: 4100,-- domains: ['openvibe.media'],-- maxBodySize: '500m',-- rateZones: [-- { name: 'openvibemedia_api', rate: '10r/s' },-- ],-- locations: [-- {-- match: '/api/',-- rateZone: 'openvibemedia_api',-- rateBurst: 40,-- readTimeout: '300s',-- sendTimeout: '300s',-- },-- ],-- headers: {-- 'X-Content-Type-Options': 'nosniff',-- 'X-Frame-Options': 'SAMEORIGIN',-- 'Referrer-Policy': 'strict-origin-when-cross-origin',-- },-- },-- live: {-- port: 3000,-- domains: ['openvibe.live', 'www.openvibe.live'],-- // WHIP ingest and the RTMP-info hostname are served by the same app.-- extraServerNames: ['whip.openvibe.live', 'ingest.openvibe.live'],-- wildcardDomain: 'openvibe.live',-- maxBodySize: '3m',-- rateZones: [-- { name: 'streamer_api', rate: '10r/s' },-- { name: 'streamer_login', rate: '5r/m' },-- ],-- // Two per-IP connection budgets: long-lived WebSockets (5-6 per open tab) must not use up the-- // budget a page load needs for its stylesheets and scripts. Mirrors OpenVibe.Live's-- // deploy/nginx/openvibe.live.conf, which is what production runs.-- connZones: ['addr_limit', 'asset_limit'],-- connLimit: { zone: 'addr_limit', n: 256 },-- // Static responses cached in front of Node; Node decides cacheability (content-hashed ?v=).-- proxyCaches: [{ path: '/var/cache/nginx/openvibe-live', zone: 'live_static', size: '20m', maxSize: '1g', inactive: '30d' }],-- gzip: true,-- // WebSocket URLs carry session tokens in the query string; keep them out of access logs.-- logWithoutQuery: true,-- // Wave 6: OpenVibe.Chat serves /ws/chat, /api/chat/, /api/dm/, /api/tts/ and /api/sounds.-- includes: ['/opt/openvibe.chat/deploy/nginx/*.locations.conf'],-- locations: [-- {-- match: '= /api/auth/register',-- rateZone: 'streamer_login',-- rateBurst: 5,-- },-- {-- match: '= /api/auth/login',-- rateZone: 'streamer_login',-- rateBurst: 10,-- },-- {-- match: '^~ /data/avatars/',-- cacheTime: '7d',-- },-- {-- match: '^~ /api/thumbnails/',-- cacheTime: '1h',-- },-- {-- match: '~ ^/api/vods/(upload|clips|stream/)',-- maxBodySize: '500m',-- readTimeout: '300s',-- sendTimeout: '300s',-- },-- {-- match: '/api/',-- rateZone: 'streamer_api',-- rateBurst: 40,-- websocket: true,-- maxBodySize: '10m',-- },-- {-- match: '/ws/',-- websocket: true,-- },-- {-- match: '~ ^/(?!api/|ws/|data/|media/).*\\.(?:css|js|mjs|map|woff2?|ttf|otf|svg|png|jpe?g|gif|ico|webp|mp3|wav)$',-- connLimit: { zone: 'asset_limit', n: 100 },-- proxyCache: 'live_static',-- },-- ],-- defaultLocationExtras: {-- websocket: true,-- headers: {-- 'Permissions-Policy': 'camera=*, microphone=*, display-capture=*',-- },-- },-- headers: {-- 'X-Content-Type-Options': 'nosniff',-- 'X-Frame-Options': 'SAMEORIGIN',-- 'Referrer-Policy': 'strict-origin-when-cross-origin',-- },-- },-- games: {-- port: 8000,-- domains: ['openvibe.games', 'www.openvibe.games', 'play.openvibe.games'],-- wildcardDomain: 'openvibe.games',-- maxBodySize: '2m',-- rateZones: [-- { name: 'openvibegames_api', rate: '10r/s' },-- { name: 'openvibegames_auth', rate: '5r/m' },-- ],-- locations: [-- {-- match: '/auth/',-- rateZone: 'openvibegames_auth',-- rateBurst: 5,-- },-- {-- match: '/api/',-- rateZone: 'openvibegames_api',-- rateBurst: 30,-- websocket: true,-- },-- ],-- headers: {-- 'X-Content-Type-Options': 'nosniff',-- 'X-Frame-Options': 'SAMEORIGIN',-- 'Referrer-Policy': 'strict-origin-when-cross-origin',-- },-- },-- maps: {-- port: 4010,-- domains: ['maps.openvibe.tools'],-- parentDomain: 'openvibe.tools',-- maxBodySize: '2m',-- rateZones: [-- { name: 'openvibemaps_api', rate: '10r/s' },-- ],-- locations: [-- {-- match: '= /api/search/stream',-- rateZone: 'openvibemaps_api',-- rateBurst: 10,-- sseStreaming: true,-- },-- {-- match: '/api/',-- rateZone: 'openvibemaps_api',-- rateBurst: 30,-- },-- ],-- headers: {-- 'X-Content-Type-Options': 'nosniff',-- 'X-Frame-Options': 'SAMEORIGIN',-- 'Referrer-Policy': 'strict-origin-when-cross-origin',-- },-- },-- food: {-- port: 4011,-- domains: ['food.openvibe.tools'],-- parentDomain: 'openvibe.tools',-- maxBodySize: '2m',-- rateZones: [-- { name: 'openvibefood_api', rate: '10r/s' },-- ],-- locations: [-- {-- match: '/api/',-- rateZone: 'openvibefood_api',-- rateBurst: 30,-- },-- ],-- headers: {-- 'X-Content-Type-Options': 'nosniff',-- 'X-Frame-Options': 'SAMEORIGIN',-- 'Referrer-Policy': 'strict-origin-when-cross-origin',-- },-- },-- img: {-- port: 4012,-- domains: ['img.openvibe.tools'],-- parentDomain: 'openvibe.tools',-- maxBodySize: '50m',-- rateZones: [-- { name: 'openvibeimg_api', rate: '5r/s' },-- ],-- locations: [-- {-- match: '/api/',-- rateZone: 'openvibeimg_api',-- rateBurst: 20,-- },-- ],-- headers: {-- 'X-Content-Type-Options': 'nosniff',-- 'X-Frame-Options': 'SAMEORIGIN',-- 'Referrer-Policy': 'strict-origin-when-cross-origin',-- },-- },-- docs: {-- port: 4016,-- domains: ['docs.openvibe.tools', 'pdf.openvibe.tools'],-- parentDomain: 'openvibe.tools',-- maxBodySize: '50m',-- rateZones: [-- { name: 'openvibedocs_api', rate: '5r/s' },-- ],-- locations: [-- {-- match: '/api/',-- rateZone: 'openvibedocs_api',-- rateBurst: 20,-- },-- ],-- headers: {-- 'X-Content-Type-Options': 'nosniff',-- 'X-Frame-Options': 'SAMEORIGIN',-- 'Referrer-Policy': 'strict-origin-when-cross-origin',-- },-- },-- yt: {-- port: 4013,-- domains: ['yt.openvibe.tools'],-- parentDomain: 'openvibe.tools',-- maxBodySize: '2m',-- rateZones: [-- { name: 'openvibeyt_api', rate: '2r/s' },-- ],-- locations: [-- {-- match: '~ ^/api/status/.+/stream$',-- sseStreaming: true,-- readTimeout: '600s',-- },-- {-- match: '~ ^/api/downloads/',-- readTimeout: '600s',-- },-- {-- match: '/api/',-- rateZone: 'openvibeyt_api',-- rateBurst: 10,-- },-- ],-- headers: {-- 'X-Content-Type-Options': 'nosniff',-- 'X-Frame-Options': 'SAMEORIGIN',-- 'Referrer-Policy': 'strict-origin-when-cross-origin',-- },-- },-- audio: {-- port: 4014,-- domains: ['audio.openvibe.tools'],-- parentDomain: 'openvibe.tools',-- maxBodySize: '100m',-- rateZones: [-- { name: 'openvibeaudio_api', rate: '5r/s' },-- ],-- locations: [-- {-- match: '/api/',-- rateZone: 'openvibeaudio_api',-- rateBurst: 20,-- },-- ],-- headers: {-- 'X-Content-Type-Options': 'nosniff',-- 'X-Frame-Options': 'SAMEORIGIN',-- 'Referrer-Policy': 'strict-origin-when-cross-origin',-- },-- },-- text: {-- port: 4015,-- domains: ['text.openvibe.tools'],-- parentDomain: 'openvibe.tools',-- maxBodySize: '2m',-- rateZones: [],-- locations: [-- {-- match: '/api/',-- },-- ],-- headers: {-- 'X-Content-Type-Options': 'nosniff',-- 'X-Frame-Options': 'SAMEORIGIN',-- 'Referrer-Policy': 'strict-origin-when-cross-origin',-- },-- },--};----// ── Nginx Config Generation ──────────────────────────────────----function indent(text, level = 1) {-- const pad = ' '.repeat(level);-- return text.split('\n').map(l => l ? pad + l : '').join('\n');--}----function generateProxyBlock(port, loc = {}) {-- const lines = [];-- lines.push(`proxy_pass http://127.0.0.1:${port};`);-- lines.push('proxy_http_version 1.1;');-- lines.push('proxy_set_header Host $host;');-- // Client address headers come only from $remote_addr (realip: the visitor when the peer is a-- // Cloudflare edge, else the TCP peer). Appending to, or passing through, a client-sent-- // X-Forwarded-For / CF-Connecting-IP lets anyone reaching the origin directly (DNS-only-- // hosts, the bare IP) choose the address upstreams ban and rate-limit by.-- lines.push('proxy_set_header X-Real-IP $remote_addr;');-- lines.push('proxy_set_header X-Forwarded-For $remote_addr;');-- lines.push('proxy_set_header CF-Connecting-IP $remote_addr;');-- lines.push('proxy_set_header X-Forwarded-Proto $scheme;');---- if (loc.websocket) {-- lines.push('proxy_set_header Upgrade $http_upgrade;');-- lines.push('proxy_set_header Connection "upgrade";');-- }---- if (loc.sseStreaming) {-- lines.push("proxy_set_header Connection '';");-- lines.push('proxy_buffering off;');-- lines.push('proxy_cache off;');-- lines.push('chunked_transfer_encoding off;');-- }---- if (loc.readTimeout) {-- lines.push(`proxy_read_timeout ${loc.readTimeout};`);-- }-- if (loc.sendTimeout) {-- lines.push(`proxy_send_timeout ${loc.sendTimeout};`);-- }---- return lines.join('\n');--}----function generateLocationBlock(port, loc) {-- const lines = [];---- if (loc.rateZone) {-- lines.push(`limit_req zone=${loc.rateZone} burst=${loc.rateBurst || 10} nodelay;`);-- }---- if (loc.localhostOnly) {-- lines.push('allow 127.0.0.1;');-- lines.push('allow ::1;');-- lines.push('deny all;');-- }---- if (loc.maxBodySize) {-- lines.push(`client_max_body_size ${loc.maxBodySize};`);-- }---- if (loc.connLimit) {-- lines.push(`limit_conn ${loc.connLimit.zone} ${loc.connLimit.n};`);-- }---- if (loc.proxyCache) {-- // Only responses the app marks cacheable are stored (nginx honours Cache-Control).-- lines.push(`proxy_cache ${loc.proxyCache};`);-- lines.push('proxy_cache_key "$host$uri$is_args$args";');-- lines.push('proxy_cache_lock on;');-- lines.push('proxy_cache_use_stale error timeout updating http_502 http_503;');-- lines.push('add_header X-Cache $upstream_cache_status always;');-- }---- lines.push(generateProxyBlock(port, loc));---- if (loc.cacheTime) {-- lines.push(`expires ${loc.cacheTime};`);-- const seconds = parseDurationToSeconds(loc.cacheTime);-- lines.push(`add_header Cache-Control "public, max-age=${seconds}";`);-- }---- if (loc.headers) {-- for (const [k, v] of Object.entries(loc.headers)) {-- lines.push(`add_header ${k} "${v}" always;`);-- }-- }---- return `location ${loc.match} {\n${indent(lines.join('\n'))}\n}`;--}----function parseDurationToSeconds(dur) {-- const match = dur.match(/^(\d+)([smhd])$/);-- if (!match) return 3600;-- const num = parseInt(match[1]);-- switch (match[2]) {-- case 's': return num;-- case 'm': return num * 60;-- case 'h': return num * 3600;-- case 'd': return num * 86400;-- default: return 3600;-- }--}----/**-- * Generate a complete Nginx server block config for a service.-- *-- * @param {string} serviceId - Service identifier-- * @param {object} svc - Service config (from DEFAULT_SERVICE_MAP or registry)-- * @param {object} opts - Options-- * @param {string} opts.certPath - Path to SSL certificate-- * @param {string} opts.keyPath - Path to SSL private key-- * @param {boolean} opts.sslEnabled - Whether to generate HTTPS block-- * @returns {string} Nginx config text-- */--function generateServiceConfig(serviceId, svc, opts = {}) {-- const lines = [];-- const allDomains = [...(svc.domains || []), ...(svc.extraServerNames || [])];-- if (svc.wildcardDomain && !svc.parentDomain) {-- // Add wildcard to server_name if this service owns the domain-- if (!allDomains.includes(`*.${svc.wildcardDomain}`)) {-- allDomains.push(`*.${svc.wildcardDomain}`);-- }-- }-- const serverNames = allDomains.join(' ');---- // Comment header-- lines.push(`# ═══════════════════════════════════════════════════════════════`);-- lines.push(`# ${serviceId} — Auto-generated Nginx Configuration`);-- lines.push(`# Generated by openvibe.network deploy system`);-- lines.push(`# DO NOT EDIT MANUALLY — managed via admin panel`);-- lines.push(`# ═══════════════════════════════════════════════════════════════`);-- lines.push('');---- // Rate limit zones-- if (svc.rateZones && svc.rateZones.length > 0) {-- for (const rz of svc.rateZones) {-- lines.push(`limit_req_zone $binary_remote_addr zone=${rz.name}:10m rate=${rz.rate};`);-- }-- lines.push('');-- }---- if (svc.logWithoutQuery) {-- lines.push(`log_format ${serviceId}_noquery '$remote_addr - $remote_user [$time_local] "$request_method $uri $server_protocol" $status $body_bytes_sent "$http_referer" "$http_user_agent"';`);-- }-- // Connection budgets and response caches-- for (const zone of svc.connZones || []) {-- lines.push(`limit_conn_zone $binary_remote_addr zone=${zone}:10m;`);-- }-- for (const c of svc.proxyCaches || []) {-- lines.push(`proxy_cache_path ${c.path} levels=1:2 keys_zone=${c.zone}:${c.size || '10m'} max_size=${c.maxSize || '512m'} inactive=${c.inactive || '7d'} use_temp_path=off;`);-- }-- if ((svc.connZones || []).length || (svc.proxyCaches || []).length) lines.push('');---- // HTTP → HTTPS redirect-- lines.push('# HTTP → HTTPS redirect');-- lines.push('server {');-- lines.push(' listen 80;');-- lines.push(' listen [::]:80;');-- lines.push(` server_name ${serverNames};`);-- lines.push(' return 301 https://$host$request_uri;');-- lines.push('}');-- lines.push('');---- // HTTPS server-- lines.push('# HTTPS server');-- lines.push('server {');-- lines.push(' listen 443 ssl;');-- lines.push(' listen [::]:443 ssl;');-- lines.push(' http2 on;');-- lines.push(` server_name ${serverNames};`);-- lines.push(' include snippets/security-txt.conf; # /.well-known/security.txt (the host\'s file; WS-R task 7)');-- lines.push('');---- // SSL certificate-- if (opts.sslEnabled !== false && opts.certPath && opts.keyPath) {-- lines.push(` ssl_certificate ${opts.certPath};`);-- lines.push(` ssl_certificate_key ${opts.keyPath};`);-- } else {-- lines.push(' # SSL certificate not configured — update via admin panel');-- lines.push(' # ssl_certificate /path/to/fullchain.pem;');-- lines.push(' # ssl_certificate_key /path/to/privkey.pem;');-- }-- lines.push('');---- // Logs-- const logName = svc.domains?.[0]?.replace(/\./g, '-') || serviceId;-- lines.push(` access_log /var/log/nginx/${logName}.access.log${svc.logWithoutQuery ? ` ${serviceId}_noquery` : ''};`);-- lines.push(` error_log /var/log/nginx/${logName}.error.log;`);-- lines.push('');---- // Body size-- lines.push(` client_max_body_size ${svc.maxBodySize || '5m'};`);-- lines.push('');---- if (svc.connLimit) {-- lines.push(` limit_conn ${svc.connLimit.zone} ${svc.connLimit.n};`);-- lines.push('');-- }---- if (svc.gzip) {-- // Compress text on the hop to the CDN too; the global config does not compress proxied responses.-- lines.push(' gzip on;');-- lines.push(' gzip_proxied any;');-- lines.push(' gzip_vary on;');-- lines.push(' gzip_comp_level 5;');-- lines.push(' gzip_min_length 1024;');-- lines.push(' gzip_types text/css application/javascript text/javascript application/json image/svg+xml text/plain application/xml text/xml;');-- lines.push('');-- }---- // Security headers-- if (svc.headers) {-- for (const [k, v] of Object.entries(svc.headers)) {-- lines.push(` add_header ${k} ${v} always;`);-- }-- lines.push('');-- }---- // Location files other services ship for this host (e.g. OpenVibe.Chat's chat paths on-- // openvibe.live). A glob include is a no-op when the service is not installed.-- for (const inc of svc.includes || []) {-- lines.push(` include ${inc};`);-- lines.push('');-- }---- // Location blocks-- if (svc.locations && svc.locations.length > 0) {-- for (const loc of svc.locations) {-- lines.push(indent(generateLocationBlock(svc.port, loc)));-- lines.push('');-- }-- }---- // Default location (catch-all)-- const defaultLoc = svc.defaultLocationExtras || {};-- const defaultLines = [];-- defaultLines.push(generateProxyBlock(svc.port, defaultLoc));-- if (defaultLoc.headers) {-- for (const [k, v] of Object.entries(defaultLoc.headers)) {-- defaultLines.push(`add_header ${k} "${v}" always;`);-- }-- }-- lines.push(indent(`location / {\n${indent(defaultLines.join('\n'))}\n}`));-- lines.push('');---- // Deny dotfiles (except .well-known)-- lines.push(indent('location ~ /\\.(?!well-known) {'));-- lines.push(indent(' deny all;'));-- lines.push(indent('}'));-- lines.push('}');---- return lines.join('\n');--}----/**-- * Generate all Nginx configs for the entire network.-- *-- * @param {object} registry - Resolved URL registry-- * @param {object} opts - Options-- * @returns {Array<{serviceId: string, filename: string, content: string}>}-- */--function generateAllConfigs(registry = {}) {-- const serviceMap = registry.DEPLOY_SERVICE_MAP?.value || DEFAULT_SERVICE_MAP;-- const deployDomains = registry.DEPLOY_DOMAINS?.value || [];---- // Build domain → cert mapping-- const domainCertMap = {};-- for (const dd of deployDomains) {-- if (dd.domain && dd.certName) {-- domainCertMap[dd.domain] = dd.certName;-- }-- }---- const configs = [];---- for (const [serviceId, svc] of Object.entries(serviceMap)) {-- // Determine which cert to use-- const certDomain = svc.parentDomain || svc.wildcardDomain || svc.domains?.[0];-- const certName = domainCertMap[certDomain] || certDomain;-- const cert = certName ? findBestCert(certName) : null;---- const filename = (svc.domains?.[0] || serviceId).replace(/\./g, '-') + '.conf';-- const content = generateServiceConfig(serviceId, svc, {-- sslEnabled: !!cert,-- certPath: cert?.fullchain || null,-- keyPath: cert?.privkey || null,-- });---- configs.push({ serviceId, filename, content, certFound: !!cert });-- }---- return configs;--}----// ── Nginx Apply / Reload ─────────────────────────────────────----/**-- * Test nginx configuration syntax.-- * @returns {Promise<{ok: boolean, output: string}>}-- */--async function testNginxConfig() {-- return new Promise((resolve) => {-- execFile('nginx', ['-t'], { timeout: 10_000 }, (err, stdout, stderr) => {-- const output = ((stdout || '') + '\n' + (stderr || '')).trim();-- if (err) {-- resolve({ ok: false, output, error: err.message });-- } else {-- resolve({ ok: true, output });-- }-- });-- });--}----/**-- * Reload nginx.-- * @returns {Promise<{ok: boolean, output: string}>}-- */--async function reloadNginx() {-- return new Promise((resolve) => {-- execFile('systemctl', ['reload', 'nginx'], { timeout: 10_000 }, (err, stdout, stderr) => {-- const output = ((stdout || '') + '\n' + (stderr || '')).trim();-- if (err) {-- resolve({ ok: false, output, error: err.message });-- } else {-- resolve({ ok: true, output });-- }-- });-- });--}----/**-- * Apply generated configs to the Nginx sites directory.-- * Creates backups, writes new configs, validates, and optionally reloads.-- *-- * @param {Array} configs - Output from generateAllConfigs()-- * @param {object} opts-- * @param {string} opts.sitesPath - Target directory (default: /etc/nginx/sites-enabled)-- * @param {string} opts.backupPath - Backup directory (default: /etc/nginx/sites-backup)-- * @param {boolean} opts.reload - Whether to reload nginx after apply-- * @param {boolean} opts.dryRun - Only validate, don't write-- * @returns {Promise<{ok: boolean, applied: string[], backed_up: string[], validation: object, reload: object}>}-- */--async function applyConfigs(configs, opts = {}) {-- const sitesPath = opts.sitesPath || '/etc/nginx/sites-enabled';-- const backupPath = opts.backupPath || '/etc/nginx/sites-backup';-- const timestamp = new Date().toISOString().replace(/[:.]/g, '-');-- const backupDir = path.join(backupPath, timestamp);---- const result = {-- ok: false,-- dryRun: !!opts.dryRun,-- applied: [],-- backed_up: [],-- validation: null,-- reload: null,-- errors: [],-- };---- if (opts.dryRun) {-- // In dry-run mode, validate configs by writing to a temp dir-- const tmpDir = path.join(require('os').tmpdir(), `nginx-preview-${timestamp}`);-- try {-- fs.mkdirSync(tmpDir, { recursive: true });-- for (const cfg of configs) {-- fs.writeFileSync(path.join(tmpDir, cfg.filename), cfg.content);-- result.applied.push(cfg.filename);-- }-- result.ok = true;-- result.validation = { ok: true, output: 'Dry run — configs generated but not applied' };-- } catch (err) {-- result.errors.push(err.message);-- } finally {-- try { fs.rmSync(tmpDir, { recursive: true }); } catch { /* ignore */ }-- }-- return result;-- }---- // Create backup directory-- try {-- fs.mkdirSync(backupDir, { recursive: true });-- } catch (err) {-- result.errors.push(`Cannot create backup dir: ${err.message}`);-- return result;-- }---- // Backup existing configs-- try {-- const existing = fs.readdirSync(sitesPath).filter(f => f.endsWith('.conf') || !f.includes('.'));-- for (const f of existing) {-- const src = path.join(sitesPath, f);-- // Resolve symlinks for backup-- const realSrc = fs.existsSync(src) ? (fs.lstatSync(src).isSymbolicLink() ? fs.readlinkSync(src) : src) : src;-- try {-- const content = fs.readFileSync(realSrc, 'utf8');-- fs.writeFileSync(path.join(backupDir, f), content);-- result.backed_up.push(f);-- } catch { /* skip unreadable files */ }-- }-- } catch (err) {-- result.errors.push(`Backup failed: ${err.message}`);-- return result;-- }---- // Write new configs-- for (const cfg of configs) {-- try {-- fs.writeFileSync(path.join(sitesPath, cfg.filename), cfg.content);-- result.applied.push(cfg.filename);-- } catch (err) {-- result.errors.push(`Write ${cfg.filename}: ${err.message}`);-- }-- }---- // Validate-- result.validation = await testNginxConfig();-- if (!result.validation.ok) {-- // Rollback — restore from backup-- result.errors.push('Nginx validation failed — rolling back');-- for (const f of result.backed_up) {-- try {-- const backup = fs.readFileSync(path.join(backupDir, f), 'utf8');-- fs.writeFileSync(path.join(sitesPath, f), backup);-- } catch { /* best effort */ }-- }-- // Remove newly written files that weren't in backup-- for (const f of result.applied) {-- if (!result.backed_up.includes(f)) {-- try { fs.unlinkSync(path.join(sitesPath, f)); } catch { /* ignore */ }-- }-- }-- return result;-- }---- // Reload if requested-- if (opts.reload) {-- result.reload = await reloadNginx();-- }---- result.ok = result.validation.ok && (!opts.reload || result.reload?.ok);-- return result;--}----module.exports = {-- DEFAULT_SERVICE_MAP,-- generateServiceConfig,-- generateAllConfigs,-- testNginxConfig,-- reloadNginx,-- applyConfigs,--};ddeleted file mode 100644@@@ -1,450 +0,0 @@--'use strict';----// ═══════════════════════════════════════════════════════════════--// Deploy Routes — Admin API for TLS / Nginx / Infrastructure--// Mounted at /api/admin/deploy. Requires admin role.--// ═══════════════════════════════════════════════════════════════----const express = require('express');--const certManager = require('./cert-manager');--const nginxGenerator = require('./nginx-generator');--const urlRegistry = require('../url-registry');--const { URL_DEFINITIONS } = require('openvibe-shared/url-resolver');----module.exports = function createDeployRoutes(db, requireAuth) {-- const router = express.Router();---- // Deploy touches TLS, nginx and live infrastructure — owner-only.-- const OWNER = (process.env.OWNER_USERNAME || 'goosely').toLowerCase();-- function requireAdmin(req, res, next) {-- const isOwner = req.user && req.user.username && req.user.username.toLowerCase() === OWNER;-- if (!req.user || req.user.role !== 'admin' || !isOwner) {-- return res.status(403).json({ ok: false, error: 'Owner access required' });-- }-- next();-- }---- async function auditLog(req, action, details) {-- try {-- await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(-- req.user.id, action, JSON.stringify(details)-- );-- } catch (err) {-- console.error('[Deploy] Audit log error:', err.message);-- }-- }---- async function getDeployConfig() {-- const resolved = await urlRegistry.getResolvedRegistry(db, process.env);-- return {-- acmeEmail: resolved.DEPLOY_ACME_EMAIL?.value || '',-- certMode: resolved.DEPLOY_CERT_MODE?.value || 'manual',-- cloudflareToken: resolved.DEPLOY_CLOUDFLARE_TOKEN?.value || '',-- domains: resolved.DEPLOY_DOMAINS?.value || [],-- nginxMode: resolved.DEPLOY_NGINX_MODE?.value || 'preview',-- nginxSitesPath: resolved.DEPLOY_NGINX_SITES_PATH?.value || '/etc/nginx/sites-enabled',-- nginxBackupPath: resolved.DEPLOY_NGINX_BACKUP_PATH?.value || '/etc/nginx/sites-backup',-- serviceMap: resolved.DEPLOY_SERVICE_MAP?.value || null,-- };-- }---- router.use(requireAuth, requireAdmin);---- // ═══════════════════════════════════════════════════════-- // System Prerequisites-- // ═══════════════════════════════════════════════════════---- router.get('/prerequisites', async (req, res) => {-- try {-- const prereqs = certManager.checkPrerequisites();-- const config = await getDeployConfig();-- res.json({-- ok: true,-- prerequisites: prereqs,-- config: {-- acmeEmail: config.acmeEmail,-- certMode: config.certMode,-- hasCloudflareToken: !!config.cloudflareToken,-- nginxMode: config.nginxMode,-- domainsConfigured: config.domains.length,-- },-- });-- } catch (err) {-- res.status(500).json({ ok: false, error: err.message });-- }-- });---- // ═══════════════════════════════════════════════════════-- // Deploy Configuration-- // ═══════════════════════════════════════════════════════---- router.get('/config', async (req, res) => {-- try {-- const config = await getDeployConfig();-- // Mask the Cloudflare token-- const masked = { ...config };-- if (masked.cloudflareToken && masked.cloudflareToken.length > 4) {-- masked.cloudflareToken = '••••' + masked.cloudflareToken.slice(-4);-- }-- res.json({ ok: true, config: masked });-- } catch (err) {-- res.status(500).json({ ok: false, error: err.message });-- }-- });---- router.put('/config', async (req, res) => {-- try {-- const allowedKeys = {-- acmeEmail: 'DEPLOY_ACME_EMAIL',-- certMode: 'DEPLOY_CERT_MODE',-- cloudflareToken: 'DEPLOY_CLOUDFLARE_TOKEN',-- domains: 'DEPLOY_DOMAINS',-- nginxMode: 'DEPLOY_NGINX_MODE',-- nginxSitesPath: 'DEPLOY_NGINX_SITES_PATH',-- nginxBackupPath: 'DEPLOY_NGINX_BACKUP_PATH',-- serviceMap: 'DEPLOY_SERVICE_MAP',-- };---- const updated = {};-- for (const [bodyKey, registryKey] of Object.entries(allowedKeys)) {-- if (!(bodyKey in req.body)) continue;-- const value = req.body[bodyKey];-- // Skip masked secrets-- if (registryKey === 'DEPLOY_CLOUDFLARE_TOKEN' && typeof value === 'string' && value.startsWith('••••')) {-- continue;-- }-- // Validate cert mode-- if (registryKey === 'DEPLOY_CERT_MODE' && !['cloudflare', 'manual', 'none'].includes(value)) {-- return res.status(400).json({ ok: false, error: 'certMode must be "cloudflare", "manual", or "none"' });-- }-- // Validate nginx mode-- if (registryKey === 'DEPLOY_NGINX_MODE' && !['preview', 'apply', 'disabled'].includes(value)) {-- return res.status(400).json({ ok: false, error: 'nginxMode must be "preview", "apply", or "disabled"' });-- }-- const entry = await urlRegistry.setRegistryEntry(db, registryKey, value, req.user.id);-- updated[bodyKey] = registryKey === 'DEPLOY_CLOUDFLARE_TOKEN' ? '(saved)' : entry.value;-- }---- await auditLog(req, 'deploy_config_update', { keys: Object.keys(updated) });-- res.json({ ok: true, updated });-- } catch (err) {-- res.status(400).json({ ok: false, error: err.message });-- }-- });---- // ═══════════════════════════════════════════════════════-- // Certificate Status-- // ═══════════════════════════════════════════════════════---- router.get('/certs', async (req, res) => {-- try {-- const status = await certManager.getCertificateStatus();-- const config = await getDeployConfig();---- // Annotate certs with domain config-- for (const cert of status.certs) {-- const matchingDomain = config.domains.find(d =>-- cert.domains?.includes(d.domain) || cert.domains?.includes(`*.${d.domain}`)-- );-- if (matchingDomain) {-- cert.managedDomain = matchingDomain;-- }-- }---- res.json({ ok: true, ...status });-- } catch (err) {-- res.status(500).json({ ok: false, error: err.message });-- }-- });---- // ═══════════════════════════════════════════════════════-- // Certificate Issuance — Cloudflare Mode-- // ═══════════════════════════════════════════════════════---- router.post('/certs/issue-cloudflare', async (req, res) => {-- try {-- const { domain } = req.body;-- if (!domain) return res.status(400).json({ ok: false, error: 'domain is required' });---- const config = await getDeployConfig();-- if (!config.acmeEmail) {-- return res.status(400).json({ ok: false, error: 'ACME email not configured. Set it in Deploy Config.' });-- }-- if (!config.cloudflareToken) {-- return res.status(400).json({ ok: false, error: 'Cloudflare token not configured. Set it in Deploy Config.' });-- }---- await auditLog(req, 'cert_issue_cloudflare_start', { domain });---- const result = await certManager.issueWildcardCloudflare({-- domain,-- email: config.acmeEmail,-- cloudflareToken: config.cloudflareToken,-- });---- await auditLog(req, 'cert_issue_cloudflare_result', {-- domain,-- ok: result.ok,-- certName: result.certName,-- error: result.error,-- });---- if (result.ok) {-- // Update domain config with cert name-- const domains = config.domains.map(d => {-- if (d.domain === domain) return { ...d, certName: result.certName };-- return d;-- });-- // If domain not in list, add it-- if (!domains.find(d => d.domain === domain)) {-- domains.push({ domain, wildcard: true, certName: result.certName, services: [] });-- }-- await urlRegistry.setRegistryEntry(db, 'DEPLOY_DOMAINS', domains, req.user.id);-- }---- res.json({ ok: result.ok, ...result });-- } catch (err) {-- res.status(500).json({ ok: false, error: err.message });-- }-- });---- // ═══════════════════════════════════════════════════════-- // Certificate Issuance — Manual DNS Mode-- // ═══════════════════════════════════════════════════════---- router.post('/certs/manual-info', async (req, res) => {-- try {-- const { domain } = req.body;-- if (!domain) return res.status(400).json({ ok: false, error: 'domain is required' });---- const config = await getDeployConfig();-- if (!config.acmeEmail) {-- return res.status(400).json({ ok: false, error: 'ACME email not configured' });-- }---- const result = await certManager.getManualChallengeInfo({-- domain,-- email: config.acmeEmail,-- });---- res.json({ ok: true, ...result });-- } catch (err) {-- res.status(500).json({ ok: false, error: err.message });-- }-- });---- router.post('/certs/issue-manual', async (req, res) => {-- try {-- const { domain } = req.body;-- if (!domain) return res.status(400).json({ ok: false, error: 'domain is required' });---- const config = await getDeployConfig();-- if (!config.acmeEmail) {-- return res.status(400).json({ ok: false, error: 'ACME email not configured' });-- }---- await auditLog(req, 'cert_issue_manual_start', { domain });---- const result = await certManager.issueWildcardManual({-- domain,-- email: config.acmeEmail,-- });---- await auditLog(req, 'cert_issue_manual_result', {-- domain,-- ok: result.ok,-- certName: result.certName,-- error: result.error,-- });---- if (result.ok) {-- const domains = config.domains.map(d => {-- if (d.domain === domain) return { ...d, certName: result.certName };-- return d;-- });-- if (!domains.find(d => d.domain === domain)) {-- domains.push({ domain, wildcard: true, certName: result.certName, services: [] });-- }-- await urlRegistry.setRegistryEntry(db, 'DEPLOY_DOMAINS', domains, req.user.id);-- }---- res.json({ ok: result.ok, ...result });-- } catch (err) {-- res.status(500).json({ ok: false, error: err.message });-- }-- });---- // ═══════════════════════════════════════════════════════-- // Certificate Renewal-- // ═══════════════════════════════════════════════════════---- router.post('/certs/renew', async (req, res) => {-- try {-- await auditLog(req, 'cert_renew_start', {});-- const result = await certManager.renewCertificates();-- await auditLog(req, 'cert_renew_result', { ok: result.ok, error: result.error });-- res.json({ ok: result.ok, ...result });-- } catch (err) {-- res.status(500).json({ ok: false, error: err.message });-- }-- });---- // ═══════════════════════════════════════════════════════-- // Nginx Config Preview-- // ═══════════════════════════════════════════════════════---- router.get('/nginx/preview', async (req, res) => {-- try {-- const resolved = await urlRegistry.getResolvedRegistry(db, process.env);-- const configs = nginxGenerator.generateAllConfigs(resolved);-- res.json({-- ok: true,-- configs: configs.map(c => ({-- serviceId: c.serviceId,-- filename: c.filename,-- content: c.content,-- certFound: c.certFound,-- })),-- });-- } catch (err) {-- res.status(500).json({ ok: false, error: err.message });-- }-- });---- router.get('/nginx/preview/:serviceId', async (req, res) => {-- try {-- const resolved = await urlRegistry.getResolvedRegistry(db, process.env);-- const configs = nginxGenerator.generateAllConfigs(resolved);-- const cfg = configs.find(c => c.serviceId === req.params.serviceId);-- if (!cfg) return res.status(404).json({ ok: false, error: 'Service not found' });-- res.json({ ok: true, ...cfg });-- } catch (err) {-- res.status(500).json({ ok: false, error: err.message });-- }-- });---- // ═══════════════════════════════════════════════════════-- // Nginx Validate-- // ═══════════════════════════════════════════════════════---- router.post('/nginx/validate', async (req, res) => {-- try {-- const result = await nginxGenerator.testNginxConfig();-- res.json({ ok: result.ok, ...result });-- } catch (err) {-- res.status(500).json({ ok: false, error: err.message });-- }-- });---- // ═══════════════════════════════════════════════════════-- // Nginx Apply (write + validate + optional reload)-- // ═══════════════════════════════════════════════════════---- router.post('/nginx/apply', async (req, res) => {-- try {-- const config = await getDeployConfig();-- if (config.nginxMode === 'disabled') {-- return res.status(400).json({ ok: false, error: 'Nginx management is disabled' });-- }---- const dryRun = config.nginxMode === 'preview' || req.body.dryRun === true;-- const reload = req.body.reload !== false && !dryRun;---- await auditLog(req, 'nginx_apply_start', { dryRun, reload });---- const resolved = await urlRegistry.getResolvedRegistry(db, process.env);-- const configs = nginxGenerator.generateAllConfigs(resolved);---- const result = await nginxGenerator.applyConfigs(configs, {-- sitesPath: config.nginxSitesPath,-- backupPath: config.nginxBackupPath,-- reload,-- dryRun,-- });---- await auditLog(req, 'nginx_apply_result', {-- ok: result.ok,-- dryRun: result.dryRun,-- applied: result.applied,-- errors: result.errors,-- });---- res.json({ ok: result.ok, ...result });-- } catch (err) {-- res.status(500).json({ ok: false, error: err.message });-- }-- });---- // ═══════════════════════════════════════════════════════-- // Nginx Reload (standalone)-- // ═══════════════════════════════════════════════════════---- router.post('/nginx/reload', async (req, res) => {-- try {-- // Validate first-- const validation = await nginxGenerator.testNginxConfig();-- if (!validation.ok) {-- return res.status(400).json({-- ok: false,-- error: 'Nginx config validation failed — will not reload',-- validation,-- });-- }---- await auditLog(req, 'nginx_reload', {});-- const result = await nginxGenerator.reloadNginx();-- res.json({ ok: result.ok, ...result, validation });-- } catch (err) {-- res.status(500).json({ ok: false, error: err.message });-- }-- });---- // ═══════════════════════════════════════════════════════-- // Managed Domains CRUD-- // ═══════════════════════════════════════════════════════---- router.get('/domains', async (req, res) => {-- try {-- const config = await getDeployConfig();-- // Annotate each domain with cert status-- const domains = (config.domains || []).map(d => {-- const cert = certManager.findBestCert(d.certName || d.domain);-- return {-- ...d,-- certExists: !!cert,-- certPath: cert?.fullchain || null,-- keyPath: cert?.privkey || null,-- certName: cert?.certName || d.certName || d.domain,-- };-- });-- res.json({ ok: true, domains });-- } catch (err) {-- res.status(500).json({ ok: false, error: err.message });-- }-- });---- router.put('/domains', async (req, res) => {-- try {-- const { domains } = req.body;-- if (!Array.isArray(domains)) {-- return res.status(400).json({ ok: false, error: 'domains must be an array' });-- }-- // Validate each domain entry-- for (const d of domains) {-- if (!d.domain || typeof d.domain !== 'string') {-- return res.status(400).json({ ok: false, error: 'Each domain entry must have a "domain" string' });-- }-- if (!/^[a-z0-9.-]+\.[a-z]{2,}$/.test(d.domain)) {-- return res.status(400).json({ ok: false, error: `Invalid domain: ${d.domain}` });-- }-- }-- const entry = await urlRegistry.setRegistryEntry(db, 'DEPLOY_DOMAINS', domains, req.user.id);-- await auditLog(req, 'deploy_domains_update', { count: domains.length });-- res.json({ ok: true, domains: entry.value });-- } catch (err) {-- res.status(400).json({ ok: false, error: err.message });-- }-- });---- return router;--};@@@ -35,7 +35,6 @@ const createSetupRoutes = require('./setup/routes');const networkAnalytics = require('./analytics/network'); // ADR-021: no IP/user id, route templates, 30-day raw retentionconst { DiscordService } = require('./discord/discord-service');const createDiscordRoutes = require('./discord/routes');--const createDeployRoutes = require('./deploy/routes');const createCoinsRoutes = require('./coins/routes');const createWalletRoutes = require('./wallet/routes');const { signToken } = require('./auth/routes');@@@ -1073,26 +1072,9 @@ app.use('/api/domains', rateLimit({ windowMs: 60_000, max: 120 }), toolDomains.papp.use('/api/admin/domains', toolDomains.adminRouter);app.use('/api/admin/discord', createDiscordRoutes(db, discordService, requireAuth, requireAdmin));--// Deploy (TLS / Nginx / Infrastructure) admin API--app.use('/api/admin/deploy', createDeployRoutes(db, requireAuth));// Registry operators (plan T2): cell and instance route weights, draining, node principal status; staff session only.app.use('/api/admin/registry', require('./registry/registry-admin').createRegistryAdmin(db, requireAuth, requireAdmin));--// SSH access provisioning info — powers the admin "SSH" tab.--// Returns non-secret connection context (host, project roots, services). The--// actual host lives in env (SSH_SERVER_HOST) so no infra detail is committed.--app.get('/api/admin/ssh-info', requireAuth, requireAdmin, (req, res) => {-- const OWNER = (process.env.OWNER_USERNAME || 'goosely').toLowerCase();-- res.json({-- ok: true,-- host: process.env.SSH_SERVER_HOST || '',-- ownerUsername: OWNER,-- projectsRoot: process.env.SSH_PROJECTS_ROOT || '/opt/openvibe.network /opt/openvibe.live',-- services: ['openvibe-network', 'openvibe-live'],-- is_owner: !!(req.user.username && req.user.username.toLowerCase() === OWNER),-- });--});--// Setup API for first-run bootstrapping and status checksapp.use('/api/setup', createSetupRoutes(db, config));@@@ -12,7 +12,7 @@ for (const [, body] of html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/g)) asconst files = fs.readdirSync(path.join(pub, 'js', 'admin')).filter((f) => f.endsWith('.js')).sort();// The module map, in load order (public/js/admin/…).--const modules = ['core.js', 'dashboard.js', 'users.js', 'moderation.js', 'settings.js', 'tools-workers.js', 'payments.js', 'glossary.js', 'ai.js', 'ai-setup.js', 'guide-rules.js', 'guide.js', 'inference.js', 'setup.js', 'content.js', 'analytics.js', 'deploy.js', 'boot.js'];++const modules = ['core.js', 'dashboard.js', 'users.js', 'moderation.js', 'settings.js', 'tools-workers.js', 'payments.js', 'glossary.js', 'ai.js', 'ai-setup.js', 'guide-rules.js', 'guide.js', 'inference.js', 'setup.js', 'content.js', 'analytics.js', 'boot.js'];assert.deepStrictEqual(files, [...modules].sort(), 'each module file exists and there is no other');const tags = [...html.matchAll(/<script\b([^>]*)\bsrc="\/js\/admin\/([^"]+)"([^>]*)><\/script>/g)];const loaded = tags.map((m) => m[2]);nnew file mode 100644@@@ -0,0 +1,29 @@++'use strict';++// Removed on purpose (the owner, 2026-11-13): account reservation keys ("no one needs it … no remnants"), the Deploy tab++// and the SSH tab ("security risk … get rid of it"; deploys are ovhost's and changes go through git.openvibe.codes, Codes++// and the agents), and the Glossary tab (its words open in place, beside the word). Nothing of them is left to call.++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const read = (p) => fs.readFileSync(path.join(__dirname, '..', p), 'utf8');++const exists = (p) => fs.existsSync(path.join(__dirname, '..', p));++++const html = read('public/admin.html');++for (const tab of ['vkeys', 'deploy', 'ssh', 'glossary']) {++ assert.ok(!html.includes(`showTab('${tab}'`) && !html.includes(`id="sec-${tab}"`), `no ${tab} tab or section`);++}++assert.ok(!/js\/admin\/deploy\.js/.test(html), 'no Deploy script');++for (const f of ['public/js/admin/deploy.js', 'server/deploy/routes.js', 'server/deploy/nginx-generator.js']) assert.ok(!exists(f), `${f} is gone`);++++const index = read('server/index.js');++assert.ok(!/api\/admin\/deploy|ssh-info|createDeployRoutes/.test(index), 'no Deploy or SSH route');++const admin = read('server/admin/routes.js');++assert.ok(!/verification-keys/.test(admin), 'no reservation key routes');++const auth = read('server/auth/routes.js');++assert.ok(!/verification_key|isUsernameReserved|redeemVerificationKey/.test(auth), 'sign-up takes no claim code');++const db = read('server/db/database.js');++assert.ok(!/VerificationKey|isUsernameReserved/.test(db), 'no reservation key helpers');++assert.ok(!/claim-code|verification_key/.test(read('public/login.html') + read('public/js/login.js')), 'the sign-up form has no claim code');++const core = read('public/js/admin/core.js');++assert.ok(!/deployCurrentSubTab|showDeploySubTab|'deploy'/.test(core), 'no Deploy routing in the panel');++console.log('admin-removed-1113: reservation keys, Deploy, SSH and the Glossary tab are gone, with nothing left to call');@@@ -46,7 +46,7 @@ for (const id of ['credits', 'byok', 'resale', 'cache', 'quota', 'workflow']) as// A machine word may appear only quoted, as the name the owner will meet elsewhere ("KEK" in the env file).for (const g of S.GLOSSARY) assert.ok(g.word && g.text.length >= 60 && !/\b(KEK|circuit|stub|Mtok)\b/.test(g.text.replace(/"[^"]*"/g, '')), `${g.id} is explained in plain words`);assert.strictEqual(new Set(ids).size, ids.length, 'each word once');--assert.strictEqual(S.glossaryLink('credits'), '<a class="ov-what" href="/admin#glossary/credits" data-glossary="credits">What does this mean?</a>');++assert.strictEqual(S.glossaryLink('credits'), '<a class="ov-what" href="#" role="button" data-glossary="credits">What does this mean?</a>');assert.strictEqual(S.glossaryLink('nope'), '', 'no link for a word the glossary does not have');// ── Setup is an owner-only tab (public/js/admin/core.js) ──ddeleted file mode 100644@@@ -1,38 +0,0 @@--/**-- * The generated openvibe.live config must carry what production's hand-maintained config-- * (OpenVibe.Live deploy/nginx/openvibe.live.conf) depends on, or regenerating it from the admin-- * panel silently drops WHIP ingest, the connection budgets, the static cache and compression.-- *-- * node test/nginx-generator-live.test.js-- */--'use strict';--const assert = require('assert');--const g = require('../server/deploy/nginx-generator');----const conf = g.generateServiceConfig('live', g.DEFAULT_SERVICE_MAP.live, { sslEnabled: true, certPath: '/c/f.pem', keyPath: '/c/k.pem' });--const must = [-- ['WHIP hostname', /server_name [^;]*whip\.openvibe\.live/],-- ['ingest hostname', /server_name [^;]*ingest\.openvibe\.live/],-- ['socket connection budget', /limit_conn_zone \$binary_remote_addr zone=addr_limit:10m;[\s\S]*limit_conn addr_limit 256;/],-- ['asset connection budget', /limit_conn_zone \$binary_remote_addr zone=asset_limit:10m;[\s\S]*limit_conn asset_limit 100;/],-- ['static cache zone', /proxy_cache_path \/var\/cache\/nginx\/openvibe-live [^;]*keys_zone=live_static:/],-- ['static cache on the asset location', /location ~ \^\/\(\?!api\/\|ws\/\|data\/\|media\/\)[^{]*\{[^}]*proxy_cache live_static;/],-- ['gzip for proxied text', /gzip_proxied any;/],-- ['upload body size', /location ~ \^\/api\/vods\/\(upload\|clips\|stream\/\) \{[^}]*client_max_body_size 500m;/],-- ['websocket upgrade on catch-all', /location \/ \{[^}]*proxy_set_header Upgrade \$http_upgrade;/],-- ['client address from realip only', /proxy_set_header CF-Connecting-IP \$remote_addr;/],-- ['no client-sent X-Forwarded-For appended', /^(?![\s\S]*proxy_add_x_forwarded_for)/],-- ['access log without query strings (tokens)', /log_format live_noquery [^;]*\$uri[^;]*;[\s\S]*access_log [^;]* live_noquery;/],-- ['capture permissions header', /Permissions-Policy "camera=\*, microphone=\*, display-capture=\*"/],-- ['OpenVibe.Chat locations (glob, before /api/)', /include \/opt\/openvibe\.chat\/deploy\/nginx\/\*\.locations\.conf;[\s\S]*location \/api\/ \{/],--];--for (const [name, re] of must) assert.ok(re.test(conf), `generated live config is missing: ${name}`);--// The asset regex must not swallow /api/ or /ws/ (a regex location beats prefix locations).--assert.ok(!/location ~ \^\/\.\*\\\.\(\?:css/.test(conf), 'asset location must exclude /api/ and /ws/');--// The Network's /shared/ location must leave Cache-Control to the app (five minutes, or immutable--// for a ?v= content-hash URL); an expires/add_header there would send a second, conflicting one.--const net = g.generateServiceConfig('network', g.DEFAULT_SERVICE_MAP.network, { sslEnabled: true, certPath: '/c/f.pem', keyPath: '/c/k.pem' });--const sharedLoc = net.match(/location \/shared\/ \{[^}]*\}/);--assert.ok(sharedLoc, 'generated network config is missing location /shared/');--assert.ok(!/expires|Cache-Control/.test(sharedLoc[0]), 'network /shared/ must not set its own cache headers');--console.log(`nginx generator (live): ${must.length} checks passed; network /shared/ leaves caching to the app`);@@@ -152,7 +152,6 @@ function secretsNeedlesFor(w, people) {return (who) => {const n = { ...w.secrets };for (const [other, h] of Object.entries(people)) if (other !== who && tokenOf(h)) n[`${other}'s bearer token`] = tokenOf(h);-- if (who !== 'admin' && who !== 'owner') n['verification key'] = w.verificationKey;return n;};}@@@ -163,15 +162,14 @@ function secretsPaths(w) {'/data/avatars/%2e%2e/network.db', '/shared/..%2f..%2fpackage.json', '/node_modules/.package-lock.json', '/api/.well-known/jwks','/.well-known/openid-configuration', '/oauth/.well-known/openid-configuration', '/api/ready', '/metrics', '/release.json', '/status','/api/v1/status', '/api/admin/secrets', '/api/admin/settings', '/api/admin/config', '/api/admin/url-registry', '/api/admin/email',-- '/api/admin/discord/', '/api/admin/integrations/github/', '/api/admin/deploy/config', '/internal/url-registry/resolved',++ '/api/admin/discord/', '/api/admin/integrations/github/', '/internal/url-registry/resolved','/internal/integrations/github-token', '/api/setup/status', '/oauth/authorize?client_id=live&redirect_uri=https%3A%2F%2Fevil.test%2F&response_type=code','/oauth/client-info?client_id=live', `/oauth/client-info?client_id=${w.dev.PA.app}`, '/api/auth/anon-identities', '/sso/check', '/fedcm/accounts'];return crawler.pathsFor(w.routes, (name) => secretsValues(w, name), { method: 'get', query: 'limit=5&all=1&debug=1&include=secret', extra });}async function checkSecretsStored(w, needlesFor) {-- const all = { ...needlesFor('nobody'), 'verification key': w.verificationKey };-- delete all['verification key']; // stored in its own table by design; only its readers are checked above++ const all = { ...needlesFor('nobody') };const logHits = crawler.leaks({ text: w.srv.logs(), headers: {} }, all).map((l) => l.label);assert.deepStrictEqual(logHits, [], 'nothing secret was logged');const tables = (await w.db.prepare("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema() AND (table_name LIKE '%outbox%' OR table_name LIKE '%audit%' OR table_name LIKE '%log%' OR table_name LIKE '%usage%' OR table_name LIKE '%changes%' OR table_name LIKE '%alerts%' OR table_name LIKE '%revisions%' OR table_name = 'notifications')").all()).map((r) => r.name);@@@ -10,7 +10,7 @@* secrets overlap) and bob's PB (app AB); an export token minted for PA* per-person data: alice's modules, blocks, follows, notifications, sessions, linked account,* history, push subscription, refresh token (OAuth code flow); an anonymous identity;-- * provider-secret copies in site_settings; a verification key; a closed old incident++ * provider-secret copies in site_settings; a closed old incident* `secrets` collects every secret value with a label, for the suites to look for in responses.* Not a test itself (no .test.js).*/@@@ -199,8 +199,6 @@ async function buildWorld({ label = 'sec', env: extraEnv = {} } = {}) {await db.prepare("INSERT INTO site_settings (key, value, type) VALUES (?, ?, 'secret') ON CONFLICT (key) DO UPDATE SET value = excluded.value, type = excluded.type").run(k, v);secrets[`db setting ${k}`] = v;}-- const vk = `VK-${crypto.randomBytes(8).toString('hex')}`;-- await db.prepare('INSERT INTO verification_keys (key, target_username, created_by) VALUES (?, ?, ?)').run(vk, 'carol', rows.staff.id);// A per-user password hash is never shown to anyone.for (const u of ['alice', 'bob', 'staff']) { await db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(`$2a$10$${u}${crypto.randomBytes(20).toString('hex')}`, users[u].id); secrets[`${u} password hash`] = (await db.prepare('SELECT password_hash FROM users WHERE id = ?').get(users[u].id)).password_hash; }@@@ -214,7 +212,7 @@ async function buildWorld({ label = 'sec', env: extraEnv = {} } = {}) {};return {dir: srv.dir, srv, db, keys, vapid, issuer: ISSUER, routes, sentinels, secrets, users, dev, callers, clientSecrets,-- liveToken, appToken, anonToken, verificationKey: vk, pushSub, shownOnce, call, serviceToken, sign, S,++ liveToken, appToken, anonToken, pushSub, shownOnce, call, serviceToken, sign, S,// Every TCP connection the server made so far, as 'host port' lines (the preload logs them).connects: () => { try { return fs.readFileSync(dump.OV_CONNECT_LOG, 'utf8').split('\n').filter(Boolean); } catch { return []; } },stop: async () => { await srv.stop(); try { fs.rmSync(dir, { recursive: true, force: true }); fs.rmSync(srv.dir, { recursive: true, force: true }); } catch { /* */ } },@@@ -137,7 +137,7 @@ const marker = (v) => ({ redacted: true, fingerprint: fp(v) });const firstTab = html.slice(html.indexOf('id="main-tabs"')).match(/<button[^>]*>/)[0];assert.match(firstTab, /showTab\('setup',this\)/, 'Setup is the first tab');assert.match(firstTab, /owner-only/, 'the Setup button is hidden from anyone but the owner');-- assert.ok(html.includes('id="sec-setup"') && html.includes('id="sec-glossary"'));++ assert.ok(html.includes('id="sec-setup"') && !html.includes('id="sec-glossary"'));const core = fs.readFileSync(path.join(ROOT, 'public', 'js', 'admin', 'core.js'), 'utf8');assert.match(core.match(/const OWNER_ONLY_TABS = new Set\(\[[^\]]*\]\)/)[0], /'setup'/, 'a non-owner who opens /admin#setup is sent to the dashboard');
@@ -68,7 +68,6 @@
@@ -68,7 +68,6 @@
<button class="admin-tab-btn owner-only" onclick="showTab('storage',this)"><i class="fa-solid fa-hard-drive"></i> Storage</button>
<button class="admin-tab-btn owner-only" onclick="showTab('storage',this)"><i class="fa-solid fa-hard-drive"></i> Storage</button>
<button class="admin-tab-btn" onclick="showTab('notifications',this)"><i class="fa-solid fa-bell"></i> Notifications</button>
<button class="admin-tab-btn" onclick="showTab('notifications',this)"><i class="fa-solid fa-bell"></i> Notifications</button>
<button class="admin-tab-btn owner-only" onclick="showTab('email',this)"><i class="fa-solid fa-envelope"></i> Email</button>
<button class="admin-tab-btn owner-only" onclick="showTab('email',this)"><i class="fa-solid fa-envelope"></i> Email</button>
- <button class="admin-tab-btn" onclick="showTab('vkeys',this)"><i class="fa-solid fa-key"></i> Keys</button>
<button class="admin-tab-btn" onclick="showTab('audit',this)"><i class="fa-solid fa-clipboard-list"></i> Audit</button>
<button class="admin-tab-btn" onclick="showTab('audit',this)"><i class="fa-solid fa-clipboard-list"></i> Audit</button>
<button class="admin-tab-btn" onclick="showTab('modlog',this)"><i class="fa-solid fa-gavel"></i> Moderation log</button>
<button class="admin-tab-btn" onclick="showTab('modlog',this)"><i class="fa-solid fa-gavel"></i> Moderation log</button>
<button class="admin-tab-btn owner-only" onclick="showTab('grants',this)"><i class="fa-solid fa-key"></i> Service grants</button>
<button class="admin-tab-btn owner-only" onclick="showTab('grants',this)"><i class="fa-solid fa-key"></i> Service grants</button>
@@ -77,9 +76,6 @@
@@ -77,9 +76,6 @@
<button class="admin-tab-btn owner-only" onclick="showTab('eventsops',this)"><i class="fa-solid fa-route"></i> Event deliveries</button>
<button class="admin-tab-btn owner-only" onclick="showTab('eventsops',this)"><i class="fa-solid fa-route"></i> Event deliveries</button>
<button class="admin-tab-btn" onclick="showTab('analytics',this)"><i class="fa-solid fa-chart-column"></i> Analytics</button>
<button class="admin-tab-btn" onclick="showTab('analytics',this)"><i class="fa-solid fa-chart-column"></i> Analytics</button>
<button class="admin-tab-btn owner-only" onclick="showTab('domains',this)"><i class="fa-solid fa-globe"></i> Domains</button>
<button class="admin-tab-btn owner-only" onclick="showTab('domains',this)"><i class="fa-solid fa-globe"></i> Domains</button>
- <button class="admin-tab-btn owner-only" onclick="showTab('deploy',this)"><i class="fa-solid fa-server"></i> Deploy</button>
- <button class="admin-tab-btn" onclick="showTab('ssh',this)"><i class="fa-solid fa-terminal"></i> SSH</button>
- <button class="admin-tab-btn" onclick="showTab('glossary',this)"><i class="fa-solid fa-book"></i> Glossary</button>
</div>
</div>
</div>
</div>
<button class="tabs-scroll-btn" id="main-tabs-right" onclick="scrollMainTabs(1)" title="Scroll right" aria-label="Scroll right">
<button class="tabs-scroll-btn" id="main-tabs-right" onclick="scrollMainTabs(1)" title="Scroll right" aria-label="Scroll right">
@@ -98,10 +94,6 @@
@@ -98,10 +94,6 @@
<div id="setup-content" class="setup"></div>
<div id="setup-content" class="setup"></div>
</div>
</div>
- <!-- Glossary: the few words the panel cannot avoid, in plain words (js/admin/glossary.js) -->
- <div class="section" id="sec-glossary">
- <div id="glossary-content"></div>
- </div>
<!-- Dashboard -->
<!-- Dashboard -->
<div class="section active admin-clarity" id="sec-dashboard">
<div class="section active admin-clarity" id="sec-dashboard">
@@ -294,17 +286,6 @@
@@ -294,17 +286,6 @@
</div>
</div>
</div>
</div>
- <!-- Verification Keys -->
- <div class="section admin-clarity" id="sec-vkeys">
- <p class="admin-intro">Account reservation keys let a person claim a reserved username. Generate one when arranging a reservation; revoking it is permanent, so create a new key if the person still needs access.</p>
- <div class="input-row">
- <span class="admin-field"><input type="text" id="vkey-username" aria-label="Username to reserve" aria-describedby="help-vkey-username" placeholder="Username to reserve..." onkeydown="if(event.key==='Enter')generateVKey()"><span class="admin-help" id="help-vkey-username"><strong>Username to reserve.</strong> Enter the username the recipient will claim, for example alex. No name is selected by default.</span></span>
- <span class="admin-field"><input type="text" id="vkey-note" aria-label="Reservation note" aria-describedby="help-vkey-note" placeholder="Note (optional)"><span class="admin-help" id="help-vkey-note"><strong>Reservation note.</strong> Optional note for staff, for example reserved for the event host. Blank adds no note.</span></span>
- <button class="btn btn-primary" onclick="generateVKey()"><i class="fa-solid fa-key"></i> Generate a reservation key</button>
- </div>
- <div id="vkeys-content"><div class="loading">Loading...</div></div>
- </div>
-
<!-- Audit Log -->
<!-- Audit Log -->
<div class="section admin-clarity" id="sec-audit">
<div class="section admin-clarity" id="sec-audit">
<p class="admin-intro">This is the history of administrative changes. Review it after an unexpected change, then use the relevant settings page to correct it.</p>
<p class="admin-intro">This is the history of administrative changes. Review it after an unexpected change, then use the relevant settings page to correct it.</p>
@@ -556,145 +537,10 @@
@@ -556,145 +537,10 @@
</div>
</div>
</div>
</div>
- <!-- Deploy -->
- <div class="section admin-clarity" id="sec-deploy">
- <p class="admin-intro">These tools manage website addresses, HTTPS certificates, and the web server. Change them when bringing a domain online or fixing routing, and review previews before applying changes.</p>
- <div class="sub-tabs" id="deploy-sub-tabs">
- <button class="active" onclick="showDeploySubTab('overview',this)">Overview</button>
- <button onclick="showDeploySubTab('certs',this)">Certificates</button>
- <button onclick="showDeploySubTab('nginx',this)">Nginx</button>
- <button onclick="showDeploySubTab('domains',this)">Domains</button>
- <button onclick="showDeploySubTab('config',this)">Config</button>
- </div>
-
- <!-- Deploy: Overview -->
- <div id="deploy-overview">
- <div class="stats-grid" id="deploy-stats"><div class="loading">Loading deploy status...</div></div>
- <div class="card" style="margin-top:16px">
- <h3><i class="fa-solid fa-circle-check"></i> Prerequisites</h3><p class="admin-intro">These checks show whether certificate and web server tools are installed and configured. Fix a missing prerequisite before issuing certificates or applying web routing.</p>
- <div id="deploy-prereqs"><div class="muted">Checking...</div></div>
- </div>
- </div>
-
- <!-- Deploy: Certificates -->
- <div id="deploy-certs-panel" style="display:none">
- <div class="card">
- <h3><i class="fa-solid fa-lock"></i> SSL/TLS Certificates</h3>
- <p class="muted" style="margin:8px 0 16px">Certificates enable encrypted HTTPS connections for your domains. Check expiry dates when a certificate is near renewal or visitors see a security error; fewer remaining days means renewal is more urgent.</p>
- <div id="deploy-certs-list"><div class="loading">Loading certificates...</div></div>
- </div>
- <div class="card" style="margin-top:16px">
- <h3><i class="fa-solid fa-plus-circle"></i> Issue New Certificate</h3><p class="admin-intro">This requests HTTPS certificates for a domain you control. Use it when adding a domain; renewal replaces expiring certificates, and issuance cannot be recalled from this page.</p>
- <div class="form-group" style="margin:12px 0">
- <label>Domain</label>
- <span class="admin-field"><input type="text" id="cert-issue-domain" aria-label="Domain for the certificate" aria-describedby="help-cert-issue-domain" placeholder="example.com" style="width:300px"><span class="admin-help" id="help-cert-issue-domain"><strong>Domain for the certificate.</strong> No domain is selected by default. Enter a domain you control, for example example.com, after its DNS is ready.</span></span>
- </div>
- <div style="display:flex;gap:8px;flex-wrap:wrap">
- <button class="btn btn-primary" onclick="issueCertCloudflare()">
- <i class="fa-brands fa-cloudflare"></i> Issue via Cloudflare DNS
- </button>
- <button class="btn btn-secondary" onclick="showManualDnsInfo()">
- <i class="fa-solid fa-hand"></i> Manual DNS
- </button>
- <button class="btn btn-secondary" onclick="renewAllCerts()">
- <i class="fa-solid fa-rotate"></i> Renew all certificates
- </button>
- </div>
- <div id="cert-issue-output" style="margin-top:12px"></div>
- </div>
- </div>
-
- <!-- Deploy: Nginx -->
- <div id="deploy-nginx-panel" style="display:none">
- <div class="card">
- <h3><i class="fa-solid fa-sitemap"></i> Nginx Configuration</h3>
- <p class="muted" style="margin:8px 0 16px">Nginx directs web requests to the right service. Preview when changing domains, then validate before applying; Apply writes configuration and reloads the web server, and undo requires restoring the backup on the server.</p>
- <div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px">
- <button class="btn btn-primary" onclick="previewNginxConfigs()">
- <i class="fa-solid fa-eye"></i> Preview All Configs
- </button>
- <button class="btn btn-secondary" onclick="validateNginx()">
- <i class="fa-solid fa-check-circle"></i> Validate Current Config
- </button>
- <button class="btn btn-warning" onclick="applyNginxConfigs()">
- <i class="fa-solid fa-upload"></i> Apply configuration and reload
- </button>
- </div>
- <div id="nginx-preview-output"></div>
- </div>
- </div>
-
- <!-- Deploy: Domains -->
- <div id="deploy-domains-panel" style="display:none">
- <div class="card">
- <h3><i class="fa-solid fa-globe"></i> Managed Domains</h3>
- <p class="muted" style="margin:8px 0 16px">These domains are included in certificate and web routing configuration. Add a domain when bringing it online, or remove it to stop managing it here; add it again to undo.</p>
- <div id="deploy-domains-list"><div class="loading">Loading...</div></div>
- <div style="margin-top:16px;display:flex;gap:8px;align-items:end;flex-wrap:wrap">
- <div class="form-group" style="margin:0">
- <label>Add Domain</label>
- <span class="admin-field"><input type="text" id="domain-add-input" aria-label="Domain to manage" aria-describedby="help-domain-add-input" placeholder="example.com" style="width:250px"><span class="admin-help" id="help-domain-add-input"><strong>Domain to manage.</strong> No domain is selected by default. Enter the base domain, for example example.com; removing it later reverses this list change.</span></span>
- </div>
- <label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text-secondary)">
- <span class="admin-field"><input type="checkbox" id="domain-add-wildcard" aria-label="Include subdomains" aria-describedby="help-domain-add-wildcard" checked><span class="admin-help" id="help-domain-add-wildcard"><strong>Include subdomains.</strong> On by default, so the certificate also covers addresses such as app.example.com. Turn it off to request only the base domain.</span></span> Wildcard (*.domain)
- </label>
- <button class="btn btn-primary" onclick="addManagedDomain()">
- <i class="fa-solid fa-plus"></i> Add domain
- </button>
- </div>
- </div>
- </div>
-
- <!-- Deploy: Config -->
- <div id="deploy-config-panel" style="display:none">
- <div class="card">
- <h3><i class="fa-solid fa-gear"></i> Deploy Configuration</h3><p class="admin-intro">These settings choose how certificates and web routing are managed. Change them when setting up the server, using Preview only until the generated configuration has been checked.</p>
- <div class="form-group" style="margin:12px 0">
- <label for="deploy-acme-email">Certificate contact email</label>
- <span class="admin-field"><input type="email" id="deploy-acme-email" aria-label="Certificate contact email" aria-describedby="help-deploy-acme-email" placeholder="[email protected]" style="width:350px"><span class="admin-help" id="help-deploy-acme-email">No contact address is configured by default. Enter an address you monitor, for example [email protected], for certificate account notices.</span></span>
- </div>
- <div class="form-group" style="margin:12px 0">
- <label for="deploy-cert-mode">How certificates are obtained</label>
- <span class="admin-field"><select id="deploy-cert-mode" aria-label="How to obtain certificates" aria-describedby="help-deploy-cert-mode" style="width:250px">
- <option value="cloudflare">Automatic — a Cloudflare token does the DNS step</option>
- <option value="manual">Manual — you add the DNS record yourself</option>
- <option value="none">External — managed outside Network</option>
- </select><span class="admin-help" id="help-deploy-cert-mode">Manual DNS is the default and requires adding a verification record yourself. Cloudflare automates that step with a token; External means certificates are managed elsewhere.</span></span>
- </div>
- <div class="form-group" style="margin:12px 0" id="deploy-cf-token-group">
- <label for="deploy-cf-token">Cloudflare DNS token <span class="muted">(needs Zone → DNS → Edit)</span></label>
- <span class="admin-field"><input type="password" id="deploy-cf-token" aria-label="Cloudflare DNS editing token" aria-describedby="help-deploy-cf-token" placeholder="••••••••" style="width:400px"><span class="admin-help" id="help-deploy-cf-token">No token is configured by default. Use a token with Zone → DNS → Edit for the managed domains; for example limit it to your website zone.</span></span>
- <div class="muted" style="font-size:11px;margin-top:4px">Token with Zone:DNS:Edit permissions for all managed domains</div>
- </div>
- <div class="form-group" style="margin:12px 0">
- <label for="deploy-nginx-mode">How the web server is managed</label>
- <span class="admin-field"><select id="deploy-nginx-mode" aria-label="How to manage the web server" aria-describedby="help-deploy-nginx-mode" style="width:250px">
- <option value="preview">Preview only — nothing is written</option>
- <option value="apply">Apply — write, check and reload the web server</option>
- <option value="disabled">Disabled — do not manage Nginx</option>
- </select><span class="admin-help" id="help-deploy-nginx-mode">Preview only is the default and writes nothing. Apply permits writing, checking, and reloading Nginx; Disabled turns management off, and restoring a backup on the server undoes applied changes.</span></span>
- </div>
- <div class="form-group" style="margin:12px 0">
- <label for="deploy-nginx-path">Web server configuration folder</label>
- <span class="admin-field"><input type="text" id="deploy-nginx-path" aria-label="Web server configuration folder" aria-describedby="help-deploy-nginx-path" placeholder="/etc/nginx/sites-enabled" style="width:400px"><span class="admin-help" id="help-deploy-nginx-path">The default is /etc/nginx/sites-enabled. Change it only if this server stores enabled website configuration in another folder.</span></span>
- </div>
- <button class="btn btn-primary" onclick="saveDeployConfig()" style="margin-top:8px">
- <i class="fa-solid fa-save"></i> Save deploy settings
- </button>
- <span id="deploy-config-status" style="margin-left:12px"></span>
- </div>
- </div>
- </div>
-
- <!-- SSH access provisioning -->
<div class="section admin-clarity" id="sec-domains">
<div class="section admin-clarity" id="sec-domains">
<p class="admin-intro">These addresses route people to tools across OpenVibe. Add or change an address when introducing a domain; remove the saved override to return to the built-in routing.</p>
<p class="admin-intro">These addresses route people to tools across OpenVibe. Add or change an address when introducing a domain; remove the saved override to return to the built-in routing.</p>
<div id="domains-content"><div class="loading">Loading...</div></div>
<div id="domains-content"><div class="loading">Loading...</div></div>
</div>
</div>
- <div class="section admin-clarity" id="sec-ssh">
- <p class="admin-intro">SSH gives staff command-line access to the development server. Use these instructions when setting up access, and choose only the permissions the person needs.</p>
- <div id="ssh-content"><div class="loading">Loading...</div></div>
- </div>
</div>
</div>
<!-- Shared scripts -->
<!-- Shared scripts -->
@@ -722,7 +568,6 @@
@@ -722,7 +568,6 @@
<script src="/js/admin/setup.js" defer></script>
<script src="/js/admin/setup.js" defer></script>
<script src="/js/admin/content.js" defer></script>
<script src="/js/admin/content.js" defer></script>
<script src="/js/admin/analytics.js" defer></script>
<script src="/js/admin/analytics.js" defer></script>
-<script src="/js/admin/deploy.js" defer></script>
<script src="/js/admin/boot.js" defer></script>
<script src="/js/admin/boot.js" defer></script>
<script src="https://openvibe.live/live-notify.js" async></script>
<script src="https://openvibe.live/live-notify.js" async></script>
</body>
</body>
@@ -155,9 +155,6 @@ a.btn{text-decoration:none}
@@ -155,9 +155,6 @@ a.btn{text-decoration:none}
.tabs-scroll-btn{width:30px;height:30px;flex-basis:30px;border-radius:8px}
.tabs-scroll-btn{width:30px;height:30px;flex-basis:30px;border-radius:8px}
}
}
.ssh-prompt{background:var(--bg-elev,#111);border:1px solid var(--border,#2a2a2a);border-radius:8px;padding:14px;white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:0.8rem;line-height:1.55;color:var(--text,#ddd);max-height:520px;overflow:auto;margin:0}
.ssh-prompt{background:var(--bg-elev,#111);border:1px solid var(--border,#2a2a2a);border-radius:8px;padding:14px;white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:0.8rem;line-height:1.55;color:var(--text,#ddd);max-height:520px;overflow:auto;margin:0}
-#sec-ssh code,#sec-payments code,#sec-cashouts code{background:var(--bg-elev,#111);border:1px solid var(--border,#2a2a2a);border-radius:5px;padding:2px 7px;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:0.8rem;display:inline-block;margin-top:3px;word-break:break-all}
-#sec-ssh .staff-pill{font-size:0.62rem;font-weight:700;text-transform:uppercase;letter-spacing:.04em;padding:3px 8px;border-radius:999px;vertical-align:middle;margin-left:8px;color:var(--warning-text, var(--warning,#f0b74a));background:color-mix(in srgb,var(--warning,#f0b74a) 18%,transparent);border:1px solid color-mix(in srgb,var(--warning,#f0b74a) 40%,transparent)}
-#sec-ssh .staff-pill.user-pill{color:var(--accent,#6ea8ff);background:color-mix(in srgb,var(--accent,#6ea8ff) 18%,transparent);border-color:color-mix(in srgb,var(--accent,#6ea8ff) 40%,transparent)}
/* ── Platform AI (AI Exchange D11 S3, owner only): one long page, sticky index, no inner tabs ───────────────────── */
/* ── Platform AI (AI Exchange D11 S3, owner only): one long page, sticky index, no inner tabs ───────────────────── */
@@ -398,22 +395,12 @@ body:has(.admin-clarity.active) .ov-sel-opt { min-height:44px; }
@@ -398,22 +395,12 @@ body:has(.admin-clarity.active) .ov-sel-opt { min-height:44px; }
.setup-foot a{color:var(--accent-light)}
.setup-foot a{color:var(--accent-light)}
.setup-services ul{margin:6px 0 0 18px;font-size:12.5px;line-height:1.7;color:var(--text-secondary)}
.setup-services ul{margin:6px 0 0 18px;font-size:12.5px;line-height:1.7;color:var(--text-secondary)}
-/* ── Glossary (/admin#glossary) ── */
-.glossary{max-width:820px}
-.glossary h2{font-size:20px;font-weight:800;margin-bottom:6px}
-.glossary-intro{font-size:13px;line-height:1.55;margin-bottom:14px}
-.glossary-list{display:grid;gap:8px}
-.glossary-item{padding:12px 14px;background:var(--bg-card);border:1px solid var(--border);border-radius:10px;scroll-margin-top:80px;transition:border-color .3s}
-.glossary-item.is-target{border-color:var(--accent)}
-.glossary-item dt{font-size:14px;font-weight:700;margin-bottom:3px}
-.glossary-item dd{font-size:13px;line-height:1.55;color:var(--text-secondary)}
-
@media(max-width:800px){.setup-row{grid-template-columns:minmax(0,1fr)}}
@media(max-width:800px){.setup-row{grid-template-columns:minmax(0,1fr)}}
/* Phones: every control at least 44 px, nothing wider than the screen. */
/* Phones: every control at least 44 px, nothing wider than the screen. */
@media(max-width:640px){
@media(max-width:640px){
.section-tabs button,.tabs-scroll-btn{min-height:44px}
.section-tabs button,.tabs-scroll-btn{min-height:44px}
.tabs-scroll-btn{width:44px;flex-basis:44px}
.tabs-scroll-btn{width:44px;flex-basis:44px}
- #sec-setup .btn,#sec-setup input:not([type=checkbox]),#sec-setup summary,#sec-glossary .btn,.ov-what-dialog .btn,#platform-ai .pai-advanced>summary,
+ #sec-setup .btn,#sec-setup input:not([type=checkbox]),#sec-setup summary,.ov-what-dialog .btn,#platform-ai .pai-advanced>summary,
#sec-ai .btn,#sec-ai input:not([type=checkbox]),#sec-ai select,#sec-ai textarea,#sec-ai summary,#sec-ai .ov-sel{min-height:44px}
#sec-ai .btn,#sec-ai input:not([type=checkbox]),#sec-ai select,#sec-ai textarea,#sec-ai summary,#sec-ai .ov-sel{min-height:44px}
#sec-setup input[type=checkbox]{width:44px;height:44px}
#sec-setup input[type=checkbox]{width:44px;height:44px}
.ov-what,.setup-foot a{display:inline-flex;align-items:center;min-height:44px}
.ov-what,.setup-foot a{display:inline-flex;align-items:center;min-height:44px}
@@ -8,7 +8,6 @@
@@ -8,7 +8,6 @@
const OV_PAGE_LINKS = [{"label":"Network","href":"/"},{"label":"Link","href":"https://openvibe.link/","icon":"fa-desktop","featured":true},{"label":"My Account","href":"https://openvibe.my/"},{"label":"Wallet","href":"/wallet"},{"label":"Themes","href":"/themes"}];
const OV_PAGE_LINKS = [{"label":"Network","href":"/"},{"label":"Link","href":"https://openvibe.link/","icon":"fa-desktop","featured":true},{"label":"My Account","href":"https://openvibe.my/"},{"label":"Wallet","href":"/wallet"},{"label":"Themes","href":"/themes"}];
const tabLoaders = {
const tabLoaders = {
setup: loadSetup,
setup: loadSetup,
- glossary: loadGlossary,
dashboard: loadDashboard,
dashboard: loadDashboard,
users: loadUsers,
users: loadUsers,
streams: loadStreams,
streams: loadStreams,
@@ -27,7 +26,6 @@ const tabLoaders = {
@@ -27,7 +26,6 @@ const tabLoaders = {
storage: loadStorage,
storage: loadStorage,
notifications: () => {},
notifications: () => {},
email: loadEmail,
email: loadEmail,
- vkeys: loadVKeys,
audit: loadAudit,
audit: loadAudit,
modlog: () => loadModLog(true),
modlog: () => loadModLog(true),
grants: () => loadGrants(),
grants: () => loadGrants(),
@@ -35,9 +33,7 @@ const tabLoaders = {
@@ -35,9 +33,7 @@ const tabLoaders = {
checklist: () => loadOperatorChecklist(),
checklist: () => loadOperatorChecklist(),
eventsops: () => loadDeliveries(),
eventsops: () => loadDeliveries(),
analytics: loadAnalytics,
analytics: loadAnalytics,
- deploy: loadDeployOverview,
domains: loadDomains,
domains: loadDomains,
- ssh: loadSSH,
};
};
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
@@ -84,7 +84,7 @@ async function api(path, opts = {}) {
@@ -84,7 +84,7 @@ async function api(path, opts = {}) {
return data;
return data;
}
}
-// Auth headers for raw fetch() calls (SSH tab, Deploy tab, etc.).
+// Auth headers for raw fetch() calls.
function authHeaders() {
function authHeaders() {
const h = {};
const h = {};
if (token) h.Authorization = 'Bearer ' + token;
if (token) h.Authorization = 'Bearer ' + token;
@@ -99,7 +99,7 @@ let currentTab = 'dashboard';
@@ -99,7 +99,7 @@ let currentTab = 'dashboard';
// 'setup' is the owner's checklist of keys and accounts (js/admin/setup.js); its API answers 403 to anyone else too.
// 'setup' is the owner's checklist of keys and accounts (js/admin/setup.js); its API answers 403 to anyone else too.
// The owner's alone (2026-11-13: Notifications too, "we don't want admins to be able to do this"). Email is open to
// The owner's alone (2026-11-13: Notifications too, "we don't want admins to be able to do this"). Email is open to
// admins as a read-only view of its delivery numbers; its settings and changes are the owner's (server/admin/routes.js).
// admins as a read-only view of its delivery numbers; its settings and changes are the owner's (server/admin/routes.js).
-const OWNER_ONLY_TABS = new Set(['guide', 'setup', 'ai-setup', 'deploy', 'domains', 'payments', 'cashouts', 'storage', 'notifications', 'settings', 'grants', 'eventsops']);
+const OWNER_ONLY_TABS = new Set(['guide', 'setup', 'ai-setup', 'domains', 'payments', 'cashouts', 'storage', 'notifications', 'settings', 'grants', 'eventsops']);
function showTab(id, btn) {
function showTab(id, btn) {
if (!tabLoaders[id]) id = 'dashboard';
if (!tabLoaders[id]) id = 'dashboard';
@@ -121,7 +121,6 @@ function showTab(id, btn) {
@@ -121,7 +121,6 @@ function showTab(id, btn) {
/admin/<tab>[/<subtab>][?last=30d] e.g. /admin/settings,
/admin/<tab>[/<subtab>][?last=30d] e.g. /admin/settings,
/admin/analytics/openvibelive?last=12h. On load a slug wins; otherwise the
/admin/analytics/openvibelive?last=12h. On load a slug wins; otherwise the
last-visited location is restored from localStorage. */
last-visited location is restored from localStorage. */
-let deployCurrentSubTab = 'overview';
let _applyingRoute = false;
let _applyingRoute = false;
function _tabButton(id) {
function _tabButton(id) {
@@ -154,7 +153,6 @@ function urlParamToPeriod(p) {
@@ -154,7 +153,6 @@ function urlParamToPeriod(p) {
function _adminPath() {
function _adminPath() {
if (currentTab === 'ai-setup') return '/admin/ai/setup';
if (currentTab === 'ai-setup') return '/admin/ai/setup';
if (currentTab === 'analytics') return '/admin/analytics/' + (analyticsCurrentSubTab || 'overview');
if (currentTab === 'analytics') return '/admin/analytics/' + (analyticsCurrentSubTab || 'overview');
- if (currentTab === 'deploy') return '/admin/deploy/' + (deployCurrentSubTab || 'overview');
if (currentTab && currentTab !== 'dashboard') return '/admin/' + currentTab;
if (currentTab && currentTab !== 'dashboard') return '/admin/' + currentTab;
return '/admin';
return '/admin';
}
}
@@ -199,9 +197,8 @@ function routeFromUrl() {
@@ -199,9 +197,8 @@ function routeFromUrl() {
const sel = document.getElementById('analytics-period'); if (sel) sel.value = per;
const sel = document.getElementById('analytics-period'); if (sel) sel.value = per;
}
}
showTab(tab, _tabButton(tab));
showTab(tab, _tabButton(tab));
- // Use the effective tab (showTab may bounce a non-owner off /admin/deploy).
+ // Use the effective tab (showTab may bounce a non-owner off an owner's tab).
if (currentTab === 'analytics' && sub) showAnalyticsSubTab(sub, _subBtn('analytics-sub-tabs', sub));
if (currentTab === 'analytics' && sub) showAnalyticsSubTab(sub, _subBtn('analytics-sub-tabs', sub));
- else if (currentTab === 'deploy' && sub) showDeploySubTab(sub, _subBtn('deploy-sub-tabs', sub));
} finally {
} finally {
_applyingRoute = false;
_applyingRoute = false;
_syncAdminUrl(true); // normalize (e.g. /admin/analytics -> /admin/analytics/overview)
_syncAdminUrl(true); // normalize (e.g. /admin/analytics -> /admin/analytics/overview)
deleted file mode 100644
deleted file mode 100644
@@ -1,633 +0,0 @@
@@ -1,633 +0,0 @@
-/* ═══════════════════════════════════════════════════════════════
- OpenVibe.Network — admin panel (/admin): domains, SSH access and the deploy tab.
- Split out of admin.html. Classic scripts, global scope: they rely on
- admin/core.js's helpers (api, esc, toast, API, token) and on the
- tabLoaders map in admin/boot.js (loaded last). No ES modules.
- ═══════════════════════════════════════════════════════════════ */
-// ── Tool domains (owner only; the server enforces it) ───────────────
-let _domCatalog = null;
-async function loadDomains() {
- const el = document.getElementById('domains-content');
- try {
- const [list, cat] = await Promise.all([api('/api/admin/domains'), _domCatalog ? Promise.resolve(_domCatalog) : api('/api/admin/domains/catalog')]);
- _domCatalog = cat;
- const byTool = {}; list.domains.forEach(d => { (byTool[d.tool_id] = byTool[d.tool_id] || []).push(d); });
- const opts = [...cat.families.map(f => ({ id: f.id, name: f.name + ' (family)' })), ...cat.tools.map(t => ({ id: t.id, name: t.name }))];
- const roleBadge = r => `<span class="badge ${r === 'canonical' ? 'badge-success' : r === 'short' ? 'badge-info' : ''}">${esc({canonical:"Main address for search engines",short:"Short public address",alias:"Redirect to the short address",mirror:"Additional address serving the tool"}[r] || adminWords(r))}</span>`;
- el.innerHTML = `
- <div class="card"><h3><i class="fa-solid fa-globe"></i> Tool domains</h3>
- <p class="muted">Every tool has a <b>canonical</b> host (used in links, sitemaps and canonical tags), a <b>short</b> host people type, any number of <b>aliases</b> that redirect to the short one, and <b>mirrors</b> that serve the tool on another address while pointing search engines at the canonical host. Custom domains work the same way: point DNS at the server, add the host here, then run <code>add-custom-domain.sh</code> on the host for nginx and TLS. Tools picks changes up within a minute.${cat.source !== 'live' ? ' <br><span class="badge badge-warning">Tools catalog unreachable: showing the built-in list</span>' : ''}</p>
- <div class="form-group" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:10px;align-items:end">
- <div class="form-field"><label for="dom-tool">Tool</label><select id="dom-tool" aria-label="Tool receiving this address" aria-describedby="help-dom-tool">${opts.map(o => `<option value="${esc(o.id)}">${esc(o.name)}</option>`).join('')}</select><span class="admin-help" id="help-dom-tool">Choose the tool or family that should open at the domain. The first catalog item is selected initially; choose the intended tool before adding the host.</span></div>
- <div class="form-field"><label for="dom-host">Host</label><input id="dom-host" aria-label="Website hostname" aria-describedby="help-dom-host" placeholder="youtubedownloadonline.com" autocomplete="off" spellcheck="false"><span class="admin-help" id="help-dom-host">No hostname is entered by default. Enter a hostname without https://, for example tools.example.com, after pointing its DNS at the server.</span></div>
- <div class="form-field"><label for="dom-role">Role</label><select id="dom-role" aria-label="How this address works" aria-describedby="help-dom-role"><option value="alias">Redirect — sends visitors to the short address</option><option value="mirror">Mirror — serves the tool here, and points search engines at the main address</option><option value="short">Short — the address people type</option><option value="canonical">Main — the address search engines are pointed at</option></select><span class="admin-help" id="help-dom-role">Redirect is the default and sends visitors to the short address. Mirror serves the tool at this address, Short sets its public address, and Main sets its preferred search-engine address.</span></div>
- <div class="form-field"><label for="dom-note">Note</label><input id="dom-note" aria-label="Staff note" aria-describedby="help-dom-note" maxlength="500"><span class="admin-help" id="help-dom-note">Optional note, blank by default. For example: campaign domain for November.</span></div>
- <div><button class="btn btn-outline" onclick="checkDomainDns()">Check DNS</button> <button class="btn btn-primary" onclick="addDomain()">Add</button></div>
- </div>
- <div id="dom-check" class="muted" style="margin-top:8px"></div>
- </div>
- <div class="card"><h3>Overrides</h3><p class="admin-intro">These saved addresses replace or extend the built-in tool addresses. Change them when routing a custom domain; delete the override to return to built-in routing.</p>
- ${list.domains.length ? `<div style="overflow-x:auto"><table class="table" style="width:100%"><thead><tr><th>Tool</th><th>Host</th><th>Role</th><th>On</th><th>Note</th><th></th></tr></thead><tbody>
- ${list.domains.map(d => `<tr><td>${esc(d.tool_id)}${d.tool_known === false ? ' <span class="badge badge-warning">unknown tool</span>' : ''}</td>
- <td><a href="https://${esc(d.host)}/" target="_blank" rel="noopener">${esc(d.host)}</a></td><td>${roleBadge(d.role)}</td>
- <td><input type="checkbox" ${d.enabled ? 'checked' : ''} onchange="updateDomain(${d.id},{enabled:this.checked})" aria-label="Enabled"></td>
- <td>${esc(d.note || '')}</td>
- <td style="white-space:nowrap"><select onchange="updateDomain(${d.id},{role:this.value})" aria-label="Role">${['canonical','short','alias','mirror'].map(r => `<option value="${r}" ${r === d.role ? 'selected' : ''}>${{canonical:'Main address for search engines',short:'Short public address',alias:'Redirect to the short address',mirror:'Additional address serving the tool'}[r]}</option>`).join('')}</select>
- <button class="btn btn-sm btn-danger" onclick="deleteDomain(${d.id},'${esc(d.host)}')">Remove override</button></td></tr>`).join('')}
- </tbody></table></div>` : '<p class="muted">No overrides yet; tools use their built-in addresses. Add a domain above when you need a custom address.</p>'}
- </div>
- <div class="card"><h3>Built-in hosts</h3><p class="admin-intro">These are the default addresses supplied by the tool catalog. Check them before adding an override so you know which address it replaces.</p><div style="overflow-x:auto"><table class="table" style="width:100%"><thead><tr><th>Tool</th><th>Canonical</th><th>Short</th><th>Aliases</th></tr></thead><tbody>
- ${cat.tools.map(t => `<tr><td>${esc(t.name)} <small class="muted">${esc(t.id)}</small></td><td>${esc(t.hosts?.canonical || '')}</td><td>${esc(t.hosts?.short || '')}</td><td>${esc((t.hosts?.aliases || []).join(', '))}</td></tr>`).join('')}
- </tbody></table></div></div>`;
- } catch (e) { el.innerHTML = `<div class="card"><p class="muted">Could not load domains: ${esc(e.message)}</p></div>`; }
-}
-async function addDomain() {
- const body = { tool_id: document.getElementById('dom-tool').value, host: document.getElementById('dom-host').value, role: document.getElementById('dom-role').value, note: document.getElementById('dom-note').value };
- try { const r = await api('/api/admin/domains', { method: 'POST', body }); toast('Domain added', 'success'); (r.warnings || []).forEach(w => toast(w, 'warning')); loadDomains(); } catch (e) { toast(e.message, 'error'); }
-}
-async function updateDomain(id, patch) { try { const r = await api('/api/admin/domains/' + id, { method: 'PUT', body: patch }); (r.warnings || []).forEach(w => toast(w, 'warning')); toast('Saved', 'success'); } catch (e) { toast(e.message, 'error'); } loadDomains(); }
-async function deleteDomain(id, host) { if (!confirm(`Remove ${host} from the tool address overrides? This changes routing after refresh. Add the same host and role again to undo.`)) return; try { await api('/api/admin/domains/' + id, { method: 'DELETE' }); toast('Removed', 'success'); } catch (e) { toast(e.message, 'error'); } loadDomains(); }
-async function checkDomainDns() {
- const out = document.getElementById('dom-check'); out.textContent = 'Checking…';
- try { const r = await api('/api/admin/domains/check', { method: 'POST', body: { host: document.getElementById('dom-host').value } }); const c = r.check || {};
- out.textContent = (c.status === 'ready' ? 'Ready. ' : '') + (c.message || c.status || ''); } catch (e) { out.textContent = e.message; }
-}
-
-// ═══════════════════════════════════════════════════════════════
-// SSH Access — owner provisions locked-down dev accounts; admins get
-// guided instructions + an agent prompt to connect and vibe-code.
-// ═══════════════════════════════════════════════════════════════
-let sshInfo = null;
-
-async function loadSSH() {
- const el = document.getElementById('ssh-content');
- if (!el) return;
- if (!sshInfo) {
- try {
- const r = await fetch(`${API}/api/admin/ssh-info`, { headers: authHeaders() });
- const d = await r.json();
- if (!d.ok) throw new Error(d.error || 'Failed to load SSH info');
- sshInfo = d;
- } catch (e) {
- el.innerHTML = `<div class="card"><p class="muted">Could not load SSH info: ${esc(e.message)}</p></div>`;
- return;
- }
- }
- if (sshInfo.is_owner) renderSSHOwner(el);
- else renderSSHUser(el);
-}
-
-function _sshSetText(id, t) { const e = document.getElementById(id); if (e) e.textContent = t; }
-function _sshSanitizeUser(v) { return (v || '').trim().toLowerCase().replace(/[^a-z0-9_-]/g, ''); }
-
-function sshCopy(id, btn) {
- const el = document.getElementById(id);
- if (!el) return;
- navigator.clipboard.writeText(el.textContent).then(() => {
- if (typeof toast === 'function') toast('Prompt copied', 'success');
- if (btn) { const o = btn.innerHTML; btn.innerHTML = '<i class="fa-solid fa-check"></i> Copied'; setTimeout(() => { btn.innerHTML = o; }, 1400); }
- }).catch(() => { if (typeof toast === 'function') toast('Copy failed', 'error'); });
-}
-
-// ── Owner view ──────────────────────────────────────────────
-function renderSSHOwner(el) {
- const hostWarn = sshInfo.host ? '' :
- `<div class="card" style="border-color:var(--warning)"><p style="color:var(--warning-text, var(--warning));margin:0"><i class="fa-solid fa-triangle-exclamation"></i> No server host is configured yet. Set <code>SSH_SERVER_HOST</code> in the openvibe-network environment so the connection details fill in automatically.</p></div>`;
- el.innerHTML = `
- <div class="card">
- <h3><i class="fa-solid fa-user-lock"></i> Provision an SSH dev account <span class="staff-pill">Owner</span></h3>
- <p class="muted">Create a locked-down Linux account so a trusted admin can SSH in and help build the sites (vibe coding with Claude). They get their own home + group access to the project folders — <b>no root</b>. Fill in the fields, copy the prompt, and hand it to your agent.</p>
- </div>
- ${hostWarn}
- <div class="card">
- <div class="form-group" style="margin-bottom:14px">
- <label>New Linux username</label>
- <input type="text" id="ssh-o-user" aria-label="New server username" aria-describedby="help-ssh-o-user" placeholder="e.g. alex" oninput="sshOwnerUpdate()" style="max-width:280px"><span class="admin-help" id="help-ssh-o-user">No name is entered by default. Use a Linux username such as alex for the account you are provisioning.</span>
- </div>
- <div class="form-group" style="margin-bottom:14px">
- <label>Their SSH <b>public</b> key <span class="muted">— ask them to run the steps in their own SSH tab and paste their <code>.pub</code> here (recommended)</span></label>
- <textarea id="ssh-o-pubkey" aria-label="SSH public key" aria-describedby="ssh-public-help" rows="3" placeholder="ssh-ed25519 AAAA... alex@openvibe" oninput="sshOwnerUpdate()" style="width:100%;font-family:ui-monospace,monospace;font-size:0.82rem"></textarea><p class="admin-help" id="ssh-public-help">No key is entered by default. Paste the person’s public .pub key, for example an ssh-ed25519 public key; never enter their private key.</p>
- </div>
- <div class="form-group">
- <label>Access level</label>
- <select id="ssh-o-access" aria-label="Server access level" aria-describedby="help-ssh-o-access" onchange="sshOwnerUpdate()" style="max-width:440px">
- <option value="code">Project code only (safest)</option>
- <option value="restart">Project code + restart app services</option>
- <option value="web">Full web + nginx — build & run their own sites (high trust)</option>
- </select><span class="admin-help" id="help-ssh-o-access">Project code only is the default. Restart also allows service restarts, and Full web permits web server management; removing access requires the owner to update the server account.</span>
- </div>
- <div id="ssh-o-webwarn" style="display:none;margin-top:12px;padding:10px 12px;border:1px solid var(--warning);border-radius:8px;background:color-mix(in srgb,var(--warning) 10%,transparent);color:var(--warning-text, var(--warning));font-size:0.84rem;line-height:1.5">
- <i class="fa-solid fa-triangle-exclamation"></i> <b>High trust:</b> web + nginx access lets this admin edit any nginx site config and reload the web server — they can serve anything and a bad config can take <em>all</em> the sites down. It still stops short of root (no package installs or arbitrary sudo), but only grant it to admins you fully trust to run the web layer with you.
- </div>
- </div>
- <div class="card">
- <div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
- <h3 style="margin:0"><i class="fa-solid fa-robot"></i> Agent prompt</h3>
- <button class="btn btn-primary" onclick="sshCopy('ssh-o-prompt', this)"><i class="fa-solid fa-copy"></i> Copy the prompt</button>
- </div>
- <p class="muted" style="margin:8px 0">Paste into Claude Code running on the server with sudo (or locally with root SSH) to create and lock down the account.</p>
- <pre id="ssh-o-prompt" class="ssh-prompt"></pre>
- </div>
- <div class="card">
- <h3><i class="fa-solid fa-shield-halved"></i> How it's locked down</h3>
- <p class="admin-intro">This lists the limits placed on a provisioned server account. Read it before you grant access, and after any change to the access level you chose above.</p>
- <ul class="muted" style="line-height:1.7;margin:0;padding-left:20px">
- <li>Key-only login — password auth is disabled for the account.</li>
- <li>Member of a shared <code>devs</code> group that owns the project dirs (<code>${esc(sshInfo.projectsRoot)}</code>) with setgid, so they can edit code and new files stay group-shared.</li>
- <li><b>Not</b> in <code>sudo</code> — no blanket root. TLS / packages / arbitrary system changes stay with you.</li>
- <li><b>Access levels</b> are additive and each grants only a tightly-scoped sudoers rule (never general root):
- <ul style="margin:4px 0 0;padding-left:18px">
- <li><b>Project code only</b> — edit code in the project dirs, nothing privileged.</li>
- <li><b>+ Restart app services</b> — adds sudo for <code>systemctl restart openvibe-network/openvibe-live</code> only.</li>
- <li><b>Full web + nginx</b> — adds group ownership of the nginx site configs + a <code>/srv/www</code> web root, plus sudo limited to <code>nginx -t</code> and reload/restart of nginx & php-fpm. Lets them build & run their own sites — high trust, since a bad config affects every site.</li>
- </ul>
- </li>
- <li>Revoke anytime: remove their key from <code>~/.ssh/authorized_keys</code>, or <code>sudo deluser --remove-home <user></code>.</li>
- </ul>
- </div>`;
- sshOwnerUpdate();
-}
-
-function sshOwnerUpdate() {
- const user = _sshSanitizeUser(document.getElementById('ssh-o-user')?.value) || '<username>';
- const pub = (document.getElementById('ssh-o-pubkey')?.value || '').trim();
- const access = document.getElementById('ssh-o-access')?.value || 'code';
- const host = sshInfo.host || '<server-host>';
- const roots = sshInfo.projectsRoot;
- const firstRoot = roots.split(/\s+/)[0];
- const pubShown = pub || "<PASTE THE USER'S SSH PUBLIC KEY HERE>";
- const isRestart = access === 'restart' || access === 'web';
- const isWeb = access === 'web';
-
- // Toggle the high-trust warning under the selector.
- const ww = document.getElementById('ssh-o-webwarn');
- if (ww) ww.style.display = isWeb ? 'block' : 'none';
-
- const steps = [
- `Create the user with no password login:\n sudo adduser --disabled-password --gecos "" ${user}`,
- `Install their SSH public key:\n sudo install -d -m 700 -o ${user} -g ${user} /home/${user}/.ssh\n echo "${pubShown}" | sudo tee /home/${user}/.ssh/authorized_keys\n sudo chmod 600 /home/${user}/.ssh/authorized_keys\n sudo chown ${user}:${user} /home/${user}/.ssh/authorized_keys`,
- `Create/ensure a shared dev group and add them:\n sudo groupadd -f devs\n sudo usermod -aG devs ${user}`,
- `Give the group access to the project dirs (setgid so new files stay shared):\n sudo chgrp -R devs ${roots}\n sudo chmod -R g+rwX ${roots}\n sudo find ${roots} -type d -exec chmod g+s {} +`,
- `Do NOT add them to the sudo group — they must not have blanket root.`,
- ];
- if (isWeb) {
- steps.push(`Grant broad web + nginx access (edit any site config, run their own sites):\n sudo groupadd -f webdev\n sudo usermod -aG webdev ${user}\n # let the group own & edit nginx site configs (setgid keeps new files group-shared)\n sudo chgrp -R webdev /etc/nginx/sites-available /etc/nginx/sites-enabled\n sudo chmod -R g+rwX /etc/nginx/sites-available /etc/nginx/sites-enabled\n sudo find /etc/nginx/sites-available /etc/nginx/sites-enabled -type d -exec chmod g+s {} +\n # a shared web root for their own sites (2775 = group-writable + setgid)\n sudo install -d -m 2775 -o root -g webdev /srv/www`);
- }
- if (isRestart) {
- const cmds = ['/usr/bin/systemctl restart openvibe-network', '/usr/bin/systemctl restart openvibe-live'];
- if (isWeb) cmds.push('/usr/sbin/nginx -t', '/usr/bin/systemctl reload nginx', '/usr/bin/systemctl restart nginx', '/usr/bin/systemctl reload php*-fpm', '/usr/bin/systemctl restart php*-fpm');
- steps.push(`Allow ONLY these exact root commands via sudo (nothing else):\n echo '${user} ALL=(root) NOPASSWD: ${cmds.join(', ')}' | sudo tee /etc/sudoers.d/${user}\n sudo chmod 440 /etc/sudoers.d/${user}\n sudo visudo -c`);
- }
- steps.push(`Verify:\n sudo -u ${user} whoami\n sudo -u ${user} ls -la ${firstRoot}${isWeb ? '\n sudo -u ' + user + ' test -w /etc/nginx/sites-enabled && echo "nginx: writable"' : ''}`);
- const numbered = steps.map((s, i) => `${i + 1}. ${s}`).join('\n\n');
-
- const purpose = isWeb
- ? 'help develop our sites AND build/run their own nginx + PHP sites on the box'
- : 'help develop the sites';
- const webNote = isWeb
- ? '\n\nNote: this tier gives them full control of the web layer (nginx configs + a /srv/www web root + reloading nginx/php-fpm) but still NOT blanket root — installing new system packages (PHP runtime, etc.) stays an owner action. If PHP-FPM is not installed yet, install it once before they start.'
- : '';
-
- const prompt =
-`You are provisioning a locked-down SSH account on my Linux server for a trusted OpenVibe admin so they can SSH in and ${purpose} (vibe coding with Claude Code). Be careful and idempotent, and do NOT grant this user root/sudo beyond exactly what is specified below.
-
-Server host: ${host}
-New username: ${user}
-Their SSH public key:
-${pubShown}
-
-Steps:
-${numbered}${webNote}
-
-Finally, tell me the exact command they should run to connect:
- ssh ${user}@${host}`;
- _sshSetText('ssh-o-prompt', prompt);
-}
-
-// ── Admin (user) view ───────────────────────────────────────
-function renderSSHUser(el) {
- const defUser = _sshSanitizeUser(currentUser.username) || 'me';
- el.innerHTML = `
- <div class="card">
- <h3><i class="fa-solid fa-terminal"></i> Connect to the dev server <span class="staff-pill user-pill">You</span></h3>
- <p class="admin-intro">This page explains how to sign in to the development server. Use it when you need server access; your private key never leaves your machine.</p>
- <p class="muted">Generate your key, send the owner (<b>${esc(sshInfo.ownerUsername)}</b>) your <b>public</b> key to authorize, then SSH in and help build the sites with Claude. Your private key never leaves your machine.</p>
- </div>
- <div class="card">
- <div class="form-group">
- <label>Your SSH username <span class="muted">— confirm the exact name with the owner</span></label>
- <input type="text" id="ssh-u-user" aria-label="Your server username" aria-describedby="help-ssh-u-user" value="${esc(defUser)}" oninput="sshUserUpdate()" style="max-width:280px"><span class="admin-help" id="help-ssh-u-user">Starts with your account username when available. Confirm the actual server login with the owner, for example alex.</span>
- </div>
- </div>
- <div class="card">
- <h3><i class="fa-solid fa-list-ol"></i> Steps</h3>
- <p class="admin-intro">These are the commands that set up your key and the connection to the server. Run them once, then use the connection line to sign in.</p>
- <ol class="muted" style="line-height:1.9;padding-left:20px;margin:0">
- <li>Generate a key pair (skip if you already have one you want to use):<br><code id="ssh-u-keygen"></code></li>
- <li>Print your <b>public</b> key and send it to the owner to authorize:<br><code id="ssh-u-cat"></code></li>
- <li>Once they confirm it's added, put this in your <code>~/.ssh/config</code>:<pre id="ssh-u-config" class="ssh-prompt" style="margin-top:6px"></pre></li>
- <li>Connect: <code id="ssh-u-connect"></code></li>
- </ol>
- </div>
- <div class="card">
- <div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
- <h3 style="margin:0"><i class="fa-solid fa-robot"></i> Agent prompt</h3>
- <button class="btn btn-primary" onclick="sshCopy('ssh-u-prompt', this)"><i class="fa-solid fa-copy"></i> Copy the prompt</button>
- </div>
- <p class="muted" style="margin:8px 0">Paste into Claude Code on your own machine — it sets up the connection and gives it full context about the server so you can start vibe coding.</p>
- <pre id="ssh-u-prompt" class="ssh-prompt"></pre>
- </div>`;
- sshUserUpdate();
-}
-
-function sshUserUpdate() {
- const host = sshInfo.host || '<server-host>';
- const user = _sshSanitizeUser(document.getElementById('ssh-u-user')?.value) || '<username>';
- const keygen = `ssh-keygen -t ed25519 -C "${user}@openvibe" -f ~/.ssh/openvibe_${user}`;
- const cat = `cat ~/.ssh/openvibe_${user}.pub`;
- const config = `Host openvibe\n HostName ${host}\n User ${user}\n IdentityFile ~/.ssh/openvibe_${user}\n ServerAliveInterval 60`;
- const connect = `ssh openvibe`;
- _sshSetText('ssh-u-keygen', keygen);
- _sshSetText('ssh-u-cat', cat);
- _sshSetText('ssh-u-config', config);
- _sshSetText('ssh-u-connect', connect);
-
- const prompt =
-`Help me connect to the OpenVibe dev server over SSH, then help me develop the sites (vibe coding with Claude Code).
-
-My SSH username: ${user}
-Server host: ${host}
-
-Do this:
-1. If I don't already have this key, generate one (leave the passphrase empty, or set one — my call):
- ${keygen}
-2. Print my PUBLIC key so I can send it to the server owner (${sshInfo.ownerUsername}) to authorize. Nothing works until they add it:
- ${cat}
-3. Add this block to my ~/.ssh/config (create the file if needed, then chmod 600 ~/.ssh/config):
-${config}
-4. Test the connection:
- ${connect}
-
-Context about the server so you can help me once I'm connected:
-- Projects live under: ${sshInfo.projectsRoot}
- (openvibe.network under /opt/openvibe.network, OpenVibe.Live under /opt/openvibe.live)
-- Services are systemd units: ${sshInfo.services.join(', ')}. Deploys are git-based —
- cd into the project, git pull --ff-only, then restart the service (if I'm permitted).
-- I'm a OpenVibe admin, not root. nginx / TLS / packages / system-level changes go through the owner.
-- Other devs share the project group, so be careful with shared files and always pull before editing.
-Once I'm connected, cd into the relevant project and let's build.`;
- _sshSetText('ssh-u-prompt', prompt);
-}
-
-// ═══════════════════════════════════════════════════════════════
-// Deploy Panel — TLS / Nginx / Infrastructure Control Plane
-// ═══════════════════════════════════════════════════════════════
-
-function showDeploySubTab(tab, btn) {
- deployCurrentSubTab = tab;
- document.querySelectorAll('#deploy-sub-tabs button').forEach(b => b.classList.remove('active'));
- (btn || _subBtn('deploy-sub-tabs', tab))?.classList.add('active');
- ['deploy-overview', 'deploy-certs-panel', 'deploy-nginx-panel', 'deploy-domains-panel', 'deploy-config-panel'].forEach(id => {
- const el = document.getElementById(id);
- if (el) el.style.display = 'none';
- });
- const map = {
- overview: 'deploy-overview',
- certs: 'deploy-certs-panel',
- nginx: 'deploy-nginx-panel',
- domains: 'deploy-domains-panel',
- config: 'deploy-config-panel',
- };
- const target = document.getElementById(map[tab]);
- if (target) target.style.display = 'block';
- if (tab === 'overview') loadDeployOverview();
- if (tab === 'certs') loadDeployCerts();
- if (tab === 'domains') loadDeployDomains();
- if (tab === 'config') loadDeployConfig();
- _syncAdminUrl();
-}
-
-async function loadDeployOverview() {
- try {
- const r = await fetch(`${API}/api/admin/deploy/prerequisites`, { headers: authHeaders() });
- const d = await r.json();
- if (!d.ok) throw new Error(d.error);
- const p = d.prerequisites;
- const c = d.config;
- document.getElementById('deploy-stats').innerHTML = `
- <div class="stat-card"><div class="value" style="color:${p.certbotInstalled ? 'var(--success)' : 'var(--danger)'}"><i class="fa-solid fa-${p.certbotInstalled ? 'check' : 'xmark'}"></i></div><div class="label">Certbot</div></div>
- <div class="stat-card"><div class="value" style="color:${p.cloudflarePluginInstalled ? 'var(--success)' : 'var(--warning)'}"><i class="fa-solid fa-${p.cloudflarePluginInstalled ? 'check' : 'minus'}"></i></div><div class="label">Cloudflare certificate plugin</div></div>
- <div class="stat-card"><div class="value" style="color:${p.nginxInstalled ? 'var(--success)' : 'var(--danger)'}"><i class="fa-solid fa-${p.nginxInstalled ? 'check' : 'xmark'}"></i></div><div class="label">Nginx</div></div>
- <div class="stat-card"><div class="value" style="color:${c.hasCloudflareToken ? 'var(--success)' : 'var(--text-muted)'}"><i class="fa-solid fa-${c.hasCloudflareToken ? 'check' : 'minus'}"></i></div><div class="label">Cloudflare access token</div></div>
- <div class="stat-card"><div class="value">${c.domainsConfigured}</div><div class="label">Domains</div></div>
- <div class="stat-card"><div class="value" style="font-size:14px">${esc({cloudflare:'Automatic DNS verification',manual:'Manual DNS verification',none:'Certificates managed elsewhere'}[c.certMode] || 'Certificate mode unavailable')}</div><div class="label">Certificate management</div></div>
- <div class="stat-card"><div class="value" style="font-size:14px">${esc({preview:'Preview only; no files written',apply:'Writing and reloading allowed',disabled:'Web server management disabled'}[c.nginxMode] || 'Web server mode unavailable')}</div><div class="label">Web server management</div></div>
- `;
- const prereqsEl = document.getElementById('deploy-prereqs');
- const items = [];
- items.push(prereqLine('Certbot installed', p.certbotInstalled));
- items.push(prereqLine('Cloudflare DNS plugin', p.cloudflarePluginInstalled, 'Optional — needed for automated DNS-01'));
- items.push(prereqLine('Let\'s Encrypt dir writable', p.letsencryptDirWritable, 'Run as root or with sudo'));
- items.push(prereqLine('Nginx installed', p.nginxInstalled));
- items.push(prereqLine('Cloudflare INI exists', p.cloudflareIniExists, 'Auto-created when token is saved'));
- items.push(prereqLine('ACME email configured', !!c.acmeEmail, c.acmeEmail || 'Not set'));
- prereqsEl.innerHTML = items.join('');
- } catch (e) {
- document.getElementById('deploy-stats').innerHTML = `<div class="muted" style="color:var(--danger-text, var(--danger))">${esc(e.message)}</div>`;
- }
-}
-
-function prereqLine(label, ok, detail) {
- const icon = ok ? '<i class="fa-solid fa-circle-check" style="color:var(--success-text, var(--success))"></i>' : '<i class="fa-solid fa-circle-xmark" style="color:var(--danger-text, var(--danger))"></i>';
- const detailHtml = detail ? ` <span class="muted" style="font-size:11px">— ${esc(detail)}</span>` : '';
- return `<div style="padding:6px 0;display:flex;align-items:center;gap:8px">${icon} ${esc(label)}${detailHtml}</div>`;
-}
-
-async function loadDeployCerts() {
- const el = document.getElementById('deploy-certs-list');
- try {
- const r = await fetch(`${API}/api/admin/deploy/certs`, { headers: authHeaders() });
- const d = await r.json();
- if (!d.ok && d.error) throw new Error(d.error);
- if (!d.certs || d.certs.length === 0) {
- el.innerHTML = '<div class="muted">No certificates found. Issue your first certificate below.</div>';
- return;
- }
- el.innerHTML = d.certs.map(c => {
- const isWild = c.domains?.some(d => d.startsWith('*.'));
- const expColor = (c.daysRemaining || 0) < 14 ? 'var(--danger)' : (c.daysRemaining || 0) < 30 ? 'var(--warning)' : 'var(--success)';
- return `<div class="card" style="margin-bottom:8px;padding:12px">
- <div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px">
- <div>
- <strong>${esc(c.name)}</strong>
- ${isWild ? '<span style="font-size:10px;background:var(--accent);color:#000;padding:1px 6px;border-radius:3px;margin-left:6px">WILDCARD</span>' : ''}
- <div class="muted" style="font-size:11px;margin-top:2px">${(c.domains || []).map(d => esc(d)).join(', ')}</div>
- </div>
- <div style="text-align:right">
- <span style="color:${expColor};font-weight:600">${c.daysRemaining != null ? 'Expires in ' + c.daysRemaining + ' days.' : 'Expiry date is unknown.'}</span>
- <div class="muted" style="font-size:11px">${c.expiry ? 'Expires ' + esc(c.expiry) : ''}</div>
- </div>
- </div>
- </div>`;
- }).join('');
- } catch (e) {
- el.innerHTML = `<div class="muted" style="color:var(--danger-text, var(--danger))">${esc(e.message)}</div>`;
- }
-}
-
-async function issueCertCloudflare() {
- const domain = document.getElementById('cert-issue-domain').value.trim();
- if (!domain) return alert('Enter a domain');
- const out = document.getElementById('cert-issue-output');
- out.innerHTML = '<div class="loading">Issuing wildcard certificate via Cloudflare DNS... This may take 30-60 seconds.</div>';
- try {
- const r = await fetch(`${API}/api/admin/deploy/certs/issue-cloudflare`, {
- method: 'POST', headers: { ...authHeaders(), 'Content-Type': 'application/json' },
- body: JSON.stringify({ domain }),
- });
- const d = await r.json();
- if (d.ok) {
- out.innerHTML = `<div style="color:var(--success-text, var(--success));padding:8px"><i class="fa-solid fa-check-circle"></i> Certificate issued: ${esc(d.certName || domain)}</div>
- <pre style="background:var(--bg-input);padding:8px;border-radius:6px;font-size:11px;max-height:200px;overflow:auto;margin-top:8px">${esc(d.output || '')}</pre>`;
- loadDeployCerts();
- } else {
- out.innerHTML = `<div style="color:var(--danger-text, var(--danger));padding:8px"><i class="fa-solid fa-xmark"></i> ${esc(d.error || 'Failed')}</div>
- <pre style="background:var(--bg-input);padding:8px;border-radius:6px;font-size:11px;max-height:200px;overflow:auto;margin-top:8px">${esc(d.output || '')}</pre>`;
- }
- } catch (e) {
- out.innerHTML = `<div style="color:var(--danger-text, var(--danger))">${esc(e.message)}</div>`;
- }
-}
-
-async function showManualDnsInfo() {
- const domain = document.getElementById('cert-issue-domain').value.trim();
- if (!domain) return alert('Enter a domain');
- const out = document.getElementById('cert-issue-output');
- out.innerHTML = '<div class="loading">Getting manual DNS challenge info...</div>';
- try {
- const r = await fetch(`${API}/api/admin/deploy/certs/manual-info`, {
- method: 'POST', headers: { ...authHeaders(), 'Content-Type': 'application/json' },
- body: JSON.stringify({ domain }),
- });
- const d = await r.json();
- const instructions = (d.instructions || []).map(i => esc(i)).join('<br>');
- out.innerHTML = `<div class="card" style="padding:16px;margin-top:8px">
- <h4 style="margin-bottom:8px"><i class="fa-solid fa-hand"></i> Manual DNS challenge</h4>
- <p class="admin-intro">These are the DNS records to add at your domain registrar so the certificate can be issued by hand. Add them exactly as written, then issue the certificate; the request fails until the records are visible.</p>
- <div style="font-size:13px;line-height:1.6">${instructions}</div>
- <div style="margin-top:16px">
- <button class="btn btn-primary" onclick="issueManualCert('${esc(domain)}')">
- <i class="fa-solid fa-ranking-star"></i> DNS Records Created — Issue Certificate
- </button>
- <div class="muted" style="font-size:11px;margin-top:4px">Only click after creating the required DNS TXT records</div>
- </div>
- </div>`;
- } catch (e) {
- out.innerHTML = `<div style="color:var(--danger-text, var(--danger))">${esc(e.message)}</div>`;
- }
-}
-
-async function issueManualCert(domain) {
- const out = document.getElementById('cert-issue-output');
- out.innerHTML = '<div class="loading">Issuing certificate with manual DNS verification... This may take 30-60 seconds.</div>';
- try {
- const r = await fetch(`${API}/api/admin/deploy/certs/issue-manual`, {
- method: 'POST', headers: { ...authHeaders(), 'Content-Type': 'application/json' },
- body: JSON.stringify({ domain }),
- });
- const d = await r.json();
- if (d.ok) {
- out.innerHTML = `<div style="color:var(--success-text, var(--success));padding:8px"><i class="fa-solid fa-check-circle"></i> Certificate issued: ${esc(d.certName || domain)}</div>
- <pre style="background:var(--bg-input);padding:8px;border-radius:6px;font-size:11px;max-height:200px;overflow:auto;margin-top:8px">${esc(d.output || '')}</pre>`;
- loadDeployCerts();
- } else {
- out.innerHTML = `<div style="color:var(--danger-text, var(--danger));padding:8px"><i class="fa-solid fa-xmark"></i> ${esc(d.error || 'Failed')}</div>
- <pre style="background:var(--bg-input);padding:8px;border-radius:6px;font-size:11px;max-height:200px;overflow:auto;margin-top:8px">${esc(d.output || '')}</pre>`;
- }
- } catch (e) {
- out.innerHTML = `<div style="color:var(--danger-text, var(--danger))">${esc(e.message)}</div>`;
- }
-}
-
-async function renewAllCerts() {
- const out = document.getElementById('cert-issue-output');
- out.innerHTML = '<div class="loading">Renewing all certificates...</div>';
- try {
- const r = await fetch(`${API}/api/admin/deploy/certs/renew`, {
- method: 'POST', headers: authHeaders(),
- });
- const d = await r.json();
- out.innerHTML = `<div style="color:${d.ok ? 'var(--success)' : 'var(--danger)'};padding:8px">
- <i class="fa-solid fa-${d.ok ? 'check-circle' : 'xmark'}"></i> ${d.ok ? 'Renewal complete' : esc(d.error || 'Failed')}
- </div>
- <pre style="background:var(--bg-input);padding:8px;border-radius:6px;font-size:11px;max-height:200px;overflow:auto;margin-top:8px">${esc(d.output || '')}</pre>`;
- if (d.ok) loadDeployCerts();
- } catch (e) {
- out.innerHTML = `<div style="color:var(--danger-text, var(--danger))">${esc(e.message)}</div>`;
- }
-}
-
-async function previewNginxConfigs() {
- const out = document.getElementById('nginx-preview-output');
- out.innerHTML = '<div class="loading">Generating Nginx configs...</div>';
- try {
- const r = await fetch(`${API}/api/admin/deploy/nginx/preview`, { headers: authHeaders() });
- const d = await r.json();
- if (!d.ok) throw new Error(d.error);
- out.innerHTML = d.configs.map(c => `
- <div class="card" style="margin-bottom:12px">
- <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
- <h4>${esc(c.serviceId)} <span class="muted" style="font-size:11px">${esc(c.filename)}</span></h4>
- <span style="font-size:11px;color:${c.certFound ? 'var(--success)' : 'var(--warning)'}">${c.certFound ? '✓ cert found' : '⚠ no cert'}</span>
- </div>
- <pre style="background:var(--bg-input);padding:12px;border-radius:6px;font-size:11px;max-height:300px;overflow:auto;white-space:pre-wrap">${esc(c.content)}</pre>
- </div>
- `).join('');
- } catch (e) {
- out.innerHTML = `<div style="color:var(--danger-text, var(--danger))">${esc(e.message)}</div>`;
- }
-}
-
-async function validateNginx() {
- const out = document.getElementById('nginx-preview-output');
- out.innerHTML = '<div class="loading">Validating Nginx config...</div>';
- try {
- const r = await fetch(`${API}/api/admin/deploy/nginx/validate`, {
- method: 'POST', headers: authHeaders(),
- });
- const d = await r.json();
- out.innerHTML = `<div class="card" style="padding:16px">
- <div style="color:${d.ok ? 'var(--success)' : 'var(--danger)'}">
- <i class="fa-solid fa-${d.ok ? 'check-circle' : 'times-circle'}"></i>
- <strong>${d.ok ? 'Configuration valid' : 'Validation failed'}</strong>
- </div>
- <pre style="background:var(--bg-input);padding:8px;border-radius:6px;font-size:11px;margin-top:8px;max-height:200px;overflow:auto">${esc(d.output || '')}</pre>
- </div>`;
- } catch (e) {
- out.innerHTML = `<div style="color:var(--danger-text, var(--danger))">${esc(e.message)}</div>`;
- }
-}
-
-async function applyNginxConfigs() {
- if (!confirm('Apply the generated web server configuration? This backs up the existing files, writes new ones, validates them, and reloads Nginx. Routing may change immediately. To undo, restore the backup on the server, validate it, and reload Nginx.')) return;
- const out = document.getElementById('nginx-preview-output');
- out.innerHTML = '<div class="loading">Applying Nginx configs...</div>';
- try {
- const r = await fetch(`${API}/api/admin/deploy/nginx/apply`, {
- method: 'POST', headers: { ...authHeaders(), 'Content-Type': 'application/json' },
- body: JSON.stringify({ reload: true }),
- });
- const d = await r.json();
- const lines = [];
- lines.push(`<div style="color:${d.ok ? 'var(--success)' : 'var(--danger)'}"><i class="fa-solid fa-${d.ok ? 'check-circle' : 'times-circle'}"></i> <strong>${d.ok ? 'Applied successfully' : 'Apply failed'}</strong></div>`);
- if (d.dryRun) lines.push('<div class="muted">Dry run — no files were written</div>');
- if (d.applied?.length) lines.push(`<div>Applied: ${d.applied.map(f => esc(f)).join(', ')}</div>`);
- if (d.backed_up?.length) lines.push(`<div class="muted">Backed up: ${d.backed_up.length} files</div>`);
- if (d.validation) lines.push(`<div>Validation: ${d.validation.ok ? '✓ passed' : '✗ failed'}</div>`);
- if (d.reload) lines.push(`<div>Reload: ${d.reload.ok ? '✓ success' : '✗ failed'}</div>`);
- if (d.errors?.length) lines.push(`<div style="color:var(--danger-text, var(--danger))">Errors: ${d.errors.map(e => esc(e)).join('; ')}</div>`);
- out.innerHTML = `<div class="card" style="padding:16px">${lines.join('')}</div>`;
- } catch (e) {
- out.innerHTML = `<div style="color:var(--danger-text, var(--danger))">${esc(e.message)}</div>`;
- }
-}
-
-let deployDomainsList = [];
-
-async function loadDeployDomains() {
- const el = document.getElementById('deploy-domains-list');
- try {
- const r = await fetch(`${API}/api/admin/deploy/domains`, { headers: authHeaders() });
- const d = await r.json();
- if (!d.ok) throw new Error(d.error);
- deployDomainsList = d.domains || [];
- if (deployDomainsList.length === 0) {
- el.innerHTML = '<div class="muted">No domains configured. Add your first domain below.</div>';
- return;
- }
- el.innerHTML = deployDomainsList.map((d, i) => `
- <div class="card" style="margin-bottom:6px;padding:10px;display:flex;justify-content:space-between;align-items:center">
- <div>
- <strong>${esc(d.domain)}</strong>
- ${d.wildcard ? '<span style="font-size:10px;background:var(--accent);color:#000;padding:1px 6px;border-radius:3px;margin-left:6px">WILDCARD</span>' : ''}
- <span class="muted" style="font-size:11px;margin-left:8px">${d.certExists ? '🔒 cert found' : '⚠ no cert'}</span>
- ${d.certName ? '<span class="muted" style="font-size:11px;margin-left:4px">(' + esc(d.certName) + ')</span>' : ''}
- </div>
- <button class="btn btn-danger btn-sm" onclick="removeManagedDomain(${i})" title="Remove">
- <i class="fa-solid fa-trash"></i>
- </button>
- </div>
- `).join('');
- } catch (e) {
- el.innerHTML = `<div class="muted" style="color:var(--danger-text, var(--danger))">${esc(e.message)}</div>`;
- }
-}
-
-async function addManagedDomain() {
- const domain = document.getElementById('domain-add-input').value.trim().toLowerCase();
- if (!domain) return;
- if (!/^[a-z0-9.-]+\.[a-z]{2,}$/.test(domain)) return alert('Invalid domain format');
- if (deployDomainsList.find(d => d.domain === domain)) return alert('Domain already added');
- const wildcard = document.getElementById('domain-add-wildcard').checked;
- deployDomainsList.push({ domain, wildcard, certName: domain, services: [] });
- await saveDomainsList();
- document.getElementById('domain-add-input').value = '';
-}
-
-async function removeManagedDomain(idx) {
- if (!confirm('Remove domain ' + deployDomainsList[idx]?.domain + '? It will leave the managed domain list. Add it again to undo; existing certificates and web server files are managed separately.')) return;
- deployDomainsList.splice(idx, 1);
- await saveDomainsList();
-}
-
-async function saveDomainsList() {
- try {
- const r = await fetch(`${API}/api/admin/deploy/domains`, {
- method: 'PUT', headers: { ...authHeaders(), 'Content-Type': 'application/json' },
- body: JSON.stringify({ domains: deployDomainsList }),
- });
- const d = await r.json();
- if (!d.ok) throw new Error(d.error);
- loadDeployDomains();
- } catch (e) {
- alert('Failed to save: ' + e.message);
- }
-}
-
-async function loadDeployConfig() {
- try {
- const r = await fetch(`${API}/api/admin/deploy/config`, { headers: authHeaders() });
- const d = await r.json();
- if (!d.ok) throw new Error(d.error);
- document.getElementById('deploy-acme-email').value = d.config.acmeEmail || '';
- document.getElementById('deploy-cert-mode').value = d.config.certMode || 'manual';
- document.getElementById('deploy-cf-token').value = d.config.cloudflareToken || '';
- document.getElementById('deploy-nginx-mode').value = d.config.nginxMode || 'preview';
- document.getElementById('deploy-nginx-path').value = d.config.nginxSitesPath || '/etc/nginx/sites-enabled';
- } catch (e) {
- document.getElementById('deploy-config-status').innerHTML = `<span style="color:var(--danger-text, var(--danger))">${esc(e.message)}</span>`;
- }
-}
-
-async function saveDeployConfig() {
- const status = document.getElementById('deploy-config-status');
- try {
- const body = {
- acmeEmail: document.getElementById('deploy-acme-email').value.trim(),
- certMode: document.getElementById('deploy-cert-mode').value,
- cloudflareToken: document.getElementById('deploy-cf-token').value,
- nginxMode: document.getElementById('deploy-nginx-mode').value,
- nginxSitesPath: document.getElementById('deploy-nginx-path').value.trim(),
- };
- const r = await fetch(`${API}/api/admin/deploy/config`, {
- method: 'PUT', headers: { ...authHeaders(), 'Content-Type': 'application/json' },
- body: JSON.stringify(body),
- });
- const d = await r.json();
- if (!d.ok) throw new Error(d.error);
- status.innerHTML = '<span style="color:var(--success-text, var(--success))"><i class="fa-solid fa-check"></i> Saved</span>';
- setTimeout(() => status.innerHTML = '', 3000);
- } catch (e) {
- status.innerHTML = `<span style="color:var(--danger-text, var(--danger))">${esc(e.message)}</span>`;
- }
-}
@@ -28,7 +28,7 @@ function glossaryEntry(id) {
@@ -28,7 +28,7 @@ function glossaryEntry(id) {
/** The small "What does this mean?" link next to a word. Opens the line in place; a new tab opens the full list. */
/** The small "What does this mean?" link next to a word. Opens the line in place; a new tab opens the full list. */
function glossaryLink(id, label) {
function glossaryLink(id, label) {
if (!glossaryEntry(id)) return '';
if (!glossaryEntry(id)) return '';
- return '<a class="ov-what" href="/admin#glossary/' + esc(id) + '" data-glossary="' + esc(id) + '">' + esc(label || 'What does this mean?') + '</a>';
+ return '<a class="ov-what" href="#" role="button" data-glossary="' + esc(id) + '">' + esc(label || 'What does this mean?') + '</a>';
}
}
function glossaryPopup(id, anchor) {
function glossaryPopup(id, anchor) {
@@ -45,17 +45,12 @@ function glossaryPopup(id, anchor) {
@@ -45,17 +45,12 @@ function glossaryPopup(id, anchor) {
p.textContent = entry.text;
p.textContent = entry.text;
const actions = document.createElement('div');
const actions = document.createElement('div');
actions.className = 'pai-dialog-actions';
actions.className = 'pai-dialog-actions';
- const all = document.createElement('a');
- all.className = 'btn btn-outline';
- all.href = '/admin#glossary/' + entry.id;
- all.textContent = 'Every word explained';
- all.addEventListener('click', (ev) => { ev.preventDefault(); dlg.close(); openGlossary(entry.id); });
const close = document.createElement('button');
const close = document.createElement('button');
close.type = 'button';
close.type = 'button';
close.className = 'btn btn-primary';
close.className = 'btn btn-primary';
close.textContent = 'Close';
close.textContent = 'Close';
close.addEventListener('click', () => dlg.close());
close.addEventListener('click', () => dlg.close());
- actions.append(all, close);
+ actions.append(close);
form.append(h, p, actions);
form.append(h, p, actions);
dlg.append(form);
dlg.append(form);
document.body.appendChild(dlg);
document.body.appendChild(dlg);
@@ -64,20 +59,6 @@ function glossaryPopup(id, anchor) {
@@ -64,20 +59,6 @@ function glossaryPopup(id, anchor) {
}
}
/** Scroll the list to one word and mark it for a moment (the list must already be drawn). */
/** Scroll the list to one word and mark it for a moment (the list must already be drawn). */
-function scrollGlossary(id) {
- if (!id) return;
- setTimeout(() => {
- const el = document.getElementById('glossary-' + id);
- if (el) { el.scrollIntoView({ block: 'center' }); el.classList.add('is-target'); setTimeout(() => el.classList.remove('is-target'), 1600); }
- }, 60);
-}
-
-/** Open the Glossary tab (which draws the list) at one word. */
-function openGlossary(id) {
- if (typeof showTab === 'function') showTab('glossary');
- scrollGlossary(id);
-}
-
// One listener for every "What does this mean?" link on the page, wherever it was drawn.
// One listener for every "What does this mean?" link on the page, wherever it was drawn.
document.addEventListener('click', (ev) => {
document.addEventListener('click', (ev) => {
const link = ev.target.closest && ev.target.closest('a[data-glossary]');
const link = ev.target.closest && ev.target.closest('a[data-glossary]');
@@ -85,15 +66,3 @@ document.addEventListener('click', (ev) => {
@@ -85,15 +66,3 @@ document.addEventListener('click', (ev) => {
ev.preventDefault();
ev.preventDefault();
glossaryPopup(link.dataset.glossary, link);
glossaryPopup(link.dataset.glossary, link);
});
});
-
-function loadGlossary() {
- const box = document.getElementById('glossary-content');
- if (!box) return;
- box.innerHTML = '<div class="glossary">'
- + '<h2>What the words mean</h2>'
- + '<p class="muted glossary-intro">The few words the admin panel cannot avoid, in plain words. A "What does this mean?" link next to a word opens its line from here.</p>'
- + '<dl class="glossary-list">' + GLOSSARY.map((g) => '<div class="glossary-item" id="glossary-' + esc(g.id) + '"><dt>' + esc(g.word) + '</dt><dd>' + esc(g.text) + '</dd></div>').join('') + '</dl></div>';
- // /admin#glossary/<word> opens the tab at that word (the tab itself was opened by the URL routing).
- const target = (location.hash.match(/^#glossary\/([a-z-]+)/) || [])[1];
- if (target) scrollGlossary(target);
-}
@@ -1,5 +1,5 @@
@@ -1,5 +1,5 @@
/* ═══════════════════════════════════════════════════════════════
/* ═══════════════════════════════════════════════════════════════
- OpenVibe.Network — admin panel (/admin): payment settings, the Billing pointer and verification keys.
+ OpenVibe.Network — admin panel (/admin): payment settings and the Billing pointer.
Split out of admin.html. Classic scripts, global scope: they rely on
Split out of admin.html. Classic scripts, global scope: they rely on
admin/core.js's helpers (api, esc, toast, API, token) and on the
admin/core.js's helpers (api, esc, toast, API, token) and on the
tabLoaders map in admin/boot.js (loaded last). No ES modules.
tabLoaders map in admin/boot.js (loaded last). No ES modules.
@@ -97,77 +97,3 @@ async function savePayments() {
@@ -97,77 +97,3 @@ async function savePayments() {
// Cashouts: the tab is a pointer to OpenVibe.Billing's console (admin.html #sec-cashouts); Live's old cashout queue
// Cashouts: the tab is a pointer to OpenVibe.Billing's console (admin.html #sec-cashouts); Live's old cashout queue
// (/api/funds) answers 409 since the Billing cutover.
// (/api/funds) answers 409 since the Billing cutover.
-
-// ═══════════════════════════════════════════════════════════════
-// Verification Keys
-// ═══════════════════════════════════════════════════════════════
-const secretStore = {};
-async function loadVKeys() {
- const c = document.getElementById('vkeys-content');
- c.innerHTML = '<div class="loading">Loading...</div>';
- try {
- const data = await api('/api/admin/verification-keys');
- const keys = data.keys || [];
- keys.forEach(k => { if (k.key) secretStore[`vk-${k.id}`] = k.key; });
- if (!keys.length) { c.innerHTML = '<p class="muted">No account reservation keys yet. Enter a username and choose Generate to reserve it.</p>'; return; }
- c.innerHTML = `
- <table class="admin-table">
- <thead><tr><th>Key</th><th>Reserved Username</th><th>Status</th><th>Note</th><th>Created</th><th>Actions</th></tr></thead>
- <tbody>${keys.map(k => `
- <tr>
- <td><code id="vk-${k.id}" style="font-size:12px;color:var(--text-muted)">••••••••</code></td>
- <td><strong>${esc(k.target_username)}</strong></td>
- <td><span class="badge badge-${k.status==='active'?'success':k.status==='used'?'info':'danger'}">${esc({ active: 'Ready to claim.', used: 'Already claimed.', revoked: 'No longer usable.' }[k.status] || 'Status unavailable.')}</span></td>
- <td style="font-size:12px">${esc(k.note || '-')}</td>
- <td style="font-size:11px">${k.created_at ? new Date(k.created_at).toLocaleDateString() : '-'}</td>
- <td style="display:flex;gap:4px">
- ${k.status === 'active' ? `
- <button class="btn btn-sm btn-outline" aria-label="Show or hide reservation key" onclick="revealVKey('vk-${k.id}')"><i class="fa-solid fa-eye"></i></button>
- <button class="btn btn-sm btn-outline" aria-label="Copy reservation key" onclick="copyVKey('vk-${k.id}')"><i class="fa-solid fa-copy"></i></button>
- <button class="btn btn-sm btn-danger" title="Revoke permanently; generate a new key to replace it" aria-label="Revoke reservation key" onclick="revokeVKey('${k.id}')"><i class="fa-solid fa-trash"></i></button>
- ` : k.status === 'used' ? `<span class="muted">Used by ${esc(k.used_by_name || '?')}</span>` : '<span class="muted">Revoked</span>'}
- </td>
- </tr>
- `).join('')}</tbody>
- </table>`;
- } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
-}
-
-function revealVKey(id) {
- const el = document.getElementById(id);
- if (!el) return;
- const revealed = el.dataset.revealed === 'true';
- el.textContent = revealed ? '••••••••' : (secretStore[id] || '••••••••');
- el.dataset.revealed = revealed ? 'false' : 'true';
- el.style.color = revealed ? 'var(--text-muted)' : 'var(--accent-light)';
-}
-
-function copyVKey(id) {
- const val = secretStore[id];
- if (!val) return toast('No key to copy', 'error');
- navigator.clipboard.writeText(val).then(() => toast('Key copied', 'success')).catch(() => toast('Copy failed', 'error'));
-}
-
-async function generateVKey() {
- const username = document.getElementById('vkey-username')?.value.trim();
- const note = document.getElementById('vkey-note')?.value.trim();
- if (!username) return toast('Enter a username', 'error');
- try {
- const data = await api('/api/admin/verification-keys', { method: 'POST', body: { target_username: username, note } });
- const key = data.key;
- if (key?.id && key?.key) secretStore[`vk-${key.id}`] = key.key;
- toast('Verification key generated', 'success');
- document.getElementById('vkey-username').value = '';
- document.getElementById('vkey-note').value = '';
- loadVKeys();
- } catch (e) { toast(e.message, 'error'); }
-}
-
-async function revokeVKey(id) {
- if (!confirm('Revoke this account reservation key? It will no longer work. This cannot be undone; generate a new key if access is needed again.')) return;
- try {
- await api(`/api/admin/verification-keys/${id}`, { method: 'DELETE' });
- toast('Key revoked', 'success');
- loadVKeys();
- } catch (e) { toast(e.message, 'error'); }
-}
@@ -868,16 +868,9 @@ async function handleRegister(e) {
@@ -868,16 +868,9 @@ async function handleRegister(e) {
username: form.get('username'),
username: form.get('username'),
email: form.get('email') || undefined,
email: form.get('email') || undefined,
password: form.get('password'),
password: form.get('password'),
- verification_key: form.get('verification_key') || undefined,
}),
}),
});
});
const data = await res.json();
const data = await res.json();
- // A reserved name: show the claim-code field only now (most people never need it).
- if (!res.ok && data.reserved) {
- const claim = document.getElementById('claim-field');
- if (claim) { claim.hidden = false; document.getElementById('claim-code').focus(); }
- throw new Error('This username is reserved. Enter its claim code, or choose another name.');
- }
if (!res.ok) throw new Error(data.error || 'Registration failed');
if (!res.ok) throw new Error(data.error || 'Registration failed');
localStorage.setItem('ov_token', data.token);
localStorage.setItem('ov_token', data.token);
@@ -125,11 +125,6 @@
@@ -125,11 +125,6 @@
<label>Email <span style="color:var(--text-dim)">(optional)</span></label>
<label>Email <span style="color:var(--text-dim)">(optional)</span></label>
<input type="email" name="email" placeholder="[email protected]" autocomplete="email">
<input type="email" name="email" placeholder="[email protected]" autocomplete="email">
</div>
</div>
- <div class="field" id="claim-field" hidden>
- <label for="claim-code">Claim code</label>
- <input type="text" id="claim-code" name="verification_key" placeholder="OPENVIBE-XXXX-XXXX-XXXX" style="font-family:monospace" autocomplete="off">
- <small class="panel-note">This name is reserved for someone. If it is yours, enter the code you were given to claim it; otherwise choose another name.</small>
- </div>
<div class="field">
<div class="field">
<label>Password</label>
<label>Password</label>
<input type="password" name="password" placeholder="Min 6 characters" required minlength="6" autocomplete="new-password">
<input type="password" name="password" placeholder="Min 6 characters" required minlength="6" autocomplete="new-password">
@@ -50,7 +50,7 @@ const AREAS = [
@@ -50,7 +50,7 @@ const AREAS = [
{ area: 'Releases and readiness', items: [
{ area: 'Releases and readiness', items: [
{ item: 'Migration and readiness', where: [{ kind: 'url', url: 'https://openvibe.network/status' }, { kind: 'cli', cli: 'ovhost status, ovhost validate <service>' }], service: 'host' },
{ item: 'Migration and readiness', where: [{ kind: 'url', url: 'https://openvibe.network/status' }, { kind: 'cli', cli: 'ovhost status, ovhost validate <service>' }], service: 'host' },
{ item: 'Compatibility status (contracts, pins, the register)', where: [{ kind: 'url', url: 'https://openvibe.services/docs' }, { kind: 'cli', cli: 'ovhost validate <service> (contracts range)' }], service: 'codes' },
{ item: 'Compatibility status (contracts, pins, the register)', where: [{ kind: 'url', url: 'https://openvibe.services/docs' }, { kind: 'cli', cli: 'ovhost validate <service> (contracts range)' }], service: 'codes' },
- { item: 'Deploys', where: [{ kind: 'tab', tab: 'deploy' }, { kind: 'cli', cli: 'ovhost deploy <service>' }], service: 'host' },
+ { item: 'Deploys', where: [{ kind: 'cli', cli: 'ovhost deploy <service>' }], service: 'host' },
{ item: 'Domains and certificates', where: [{ kind: 'tab', tab: 'domains' }, { kind: 'cli', cli: 'ovhost certs' }], service: 'host' },
{ item: 'Domains and certificates', where: [{ kind: 'tab', tab: 'domains' }, { kind: 'cli', cli: 'ovhost certs' }], service: 'host' },
] },
] },
];
];
@@ -912,84 +912,6 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -912,84 +912,6 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
}
}
});
});
- // ═══════════════════════════════════════════════════════
- // Verification Keys (reserved username claims)
- // ═══════════════════════════════════════════════════════
-
- router.get('/verification-keys', async (req, res) => {
- try {
- const keys = await db.getAllVerificationKeys();
- res.json({ ok: true, keys });
- } catch (err) {
- res.status(500).json({ ok: false, error: err.message });
- }
- });
-
- router.post('/verification-keys', async (req, res) => {
- try {
- const { target_username, note } = req.body;
- if (!target_username) {
- return res.status(400).json({ ok: false, error: 'Target username is required' });
- }
- if (!/^[a-zA-Z0-9_]+$/.test(target_username) || target_username.length < 3 || target_username.length > 24) {
- return res.status(400).json({ ok: false, error: 'Invalid username format (3-24 chars, alphanumeric + underscore)' });
- }
-
- // Check if username already taken
- const existingUser = await db.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?)').get(target_username);
- if (existingUser) {
- return res.status(409).json({ ok: false, error: `Username "${target_username}" is already registered` });
- }
-
- // Check for duplicate active key
- const existingKey = await db.getVerificationKeyByUsername(target_username);
- if (existingKey) {
- return res.status(409).json({ ok: false, error: `Active key already exists for "${target_username}"` });
- }
-
- // Generate OPENVIBE-XXXX-XXXX-XXXX
- const crypto = require('crypto');
- const key = 'OPENVIBE-' + [4, 4, 4].map(() =>
- crypto.randomBytes(2).toString('hex').toUpperCase()
- ).join('-');
-
- await db.createVerificationKey({
- key,
- target_username,
- note: note || '',
- created_by: req.user.id,
- });
-
- const created = await db.getVerificationKeyByKey(key);
-
- await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
- req.user.id, 'verification_key_create', JSON.stringify({ key, target_username })
- );
-
- res.status(201).json({ ok: true, key: created });
- } catch (err) {
- console.error('[Admin] Verification key error:', err.message);
- res.status(500).json({ ok: false, error: 'Failed to generate key' });
- }
- });
-
- router.delete('/verification-keys/:id', async (req, res) => {
- try {
- const result = await db.revokeVerificationKey(req.params.id);
- if (result.changes === 0) {
- return res.status(404).json({ ok: false, error: 'Key not found or already used/revoked' });
- }
-
- await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
- req.user.id, 'verification_key_revoke', JSON.stringify({ keyId: req.params.id })
- );
-
- res.json({ ok: true, message: 'Verification key revoked' });
- } catch (err) {
- res.status(500).json({ ok: false, error: 'Failed to revoke key' });
- }
- });
-
// ═══════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════
// Net.OpenVibe — Network Tools Config
// Net.OpenVibe — Network Tools Config
// ═══════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════
@@ -124,7 +124,6 @@ router.post('/register', async (req, res) => {
@@ -124,7 +124,6 @@ router.post('/register', async (req, res) => {
const username = String(req.body.username || '').trim();
const username = String(req.body.username || '').trim();
const password = String(req.body.password || '');
const password = String(req.body.password || '');
const email = req.body.email ? String(req.body.email).trim() : undefined;
const email = req.body.email ? String(req.body.email).trim() : undefined;
- const verification_key = req.body.verification_key ? String(req.body.verification_key).trim() : undefined;
if (!username || !password) return res.status(400).json({ error: 'Username and password required' });
if (!username || !password) return res.status(400).json({ error: 'Username and password required' });
if (username.length < 3 || username.length > 24) {
if (username.length < 3 || username.length > 24) {
@@ -146,24 +145,6 @@ router.post('/register', async (req, res) => {
@@ -146,24 +145,6 @@ router.post('/register', async (req, res) => {
// A name someone was renamed away from stays theirs for a while (links, mentions, reputation).
// A name someone was renamed away from stays theirs for a while (links, mentions, reputation).
if (await require('../identity/usernames').isReserved(db, username)) return res.status(409).json({ error: 'Username already taken' });
if (await require('../identity/usernames').isReserved(db, username)) return res.status(409).json({ error: 'Username already taken' });
- // Check if username is reserved (has an active verification key)
- const reserved = await db.isUsernameReserved(username);
- if (reserved) {
- if (!verification_key) {
- return res.status(403).json({
- error: 'This username is reserved. A verification key is required to claim it.',
- reserved: true,
- });
- }
- const vk = await db.getVerificationKeyByKey(verification_key);
- if (!vk || vk.status !== 'active') {
- return res.status(403).json({ error: 'Invalid or expired verification key' });
- }
- if (vk.target_username.toLowerCase() !== username.toLowerCase()) {
- return res.status(403).json({ error: 'This verification key is for a different username' });
- }
- }
-
if (email) {
if (email) {
const emailExists = await db.prepare('SELECT id FROM users WHERE LOWER(email) = LOWER(?)').get(email);
const emailExists = await db.prepare('SELECT id FROM users WHERE LOWER(email) = LOWER(?)').get(email);
if (emailExists) return res.status(409).json({ error: 'Email already in use' });
if (emailExists) return res.status(409).json({ error: 'Email already in use' });
@@ -178,15 +159,10 @@ router.post('/register', async (req, res) => {
@@ -178,15 +159,10 @@ router.post('/register', async (req, res) => {
// Create default preferences
// Create default preferences
await db.prepare('INSERT INTO user_preferences (user_id) VALUES (?) ON CONFLICT DO NOTHING').run(result.lastInsertRowid);
await db.prepare('INSERT INTO user_preferences (user_id) VALUES (?) ON CONFLICT DO NOTHING').run(result.lastInsertRowid);
- // Redeem verification key if used
- if (verification_key && reserved) {
- await db.redeemVerificationKey(verification_key, result.lastInsertRowid);
- }
-
const user = await db.prepare('SELECT * FROM users WHERE id = ?').get(result.lastInsertRowid);
const user = await db.prepare('SELECT * FROM users WHERE id = ?').get(result.lastInsertRowid);
const token = signToken(user, req.app.locals.privateKey, config);
const token = signToken(user, req.app.locals.privateKey, config);
- console.log(`[Auth] New user registered: ${username} (id: ${user.id})${reserved ? ' [verification key redeemed]' : ''}`);
+ console.log(`[Auth] New user registered: ${username} (id: ${user.id})`);
// Confirm the address before it can receive opt-in mail (best-effort, rate-limited).
// Confirm the address before it can receive opt-in mail (best-effort, rate-limited).
if (user.email) { try { require('./email-verify').sendVerification(req, user).catch(() => {}); } catch { /* */ } }
if (user.email) { try { require('./email-verify').sendVerification(req, user).catch(() => {}); } catch { /* */ } }
@@ -463,37 +463,6 @@ function attachHelpers(db) {
@@ -463,37 +463,6 @@ function attachHelpers(db) {
return row.value;
return row.value;
};
};
- db.createVerificationKey = async function ({ key, target_username, note, created_by }) {
- return await db.prepare('INSERT INTO verification_keys (key, target_username, note, created_by) VALUES (?, ?, ?, ?)')
- .run(key, target_username, note || '', created_by);
- };
- db.getVerificationKeyByKey = async function (key) {
- return await db.prepare('SELECT * FROM verification_keys WHERE key = ?').get(key);
- };
- db.getVerificationKeyByUsername = async function (username) {
- return await db.prepare("SELECT * FROM verification_keys WHERE lower(target_username) = lower(?) AND status = 'active'").get(username);
- };
- db.getAllVerificationKeys = async function () {
- return await db.prepare(`
- SELECT vk.*, u1.username as created_by_name, u2.username as used_by_name
- FROM verification_keys vk
- LEFT JOIN users u1 ON vk.created_by = u1.id
- LEFT JOIN users u2 ON vk.used_by = u2.id
- ORDER BY vk.created_at DESC
- `).all();
- };
- db.redeemVerificationKey = async function (key, userId) {
- return await db.prepare("UPDATE verification_keys SET status = 'used', used_by = ?, used_at = ov_now() WHERE key = ? AND status = 'active'")
- .run(userId, key);
- };
- db.revokeVerificationKey = async function (id) {
- return await db.prepare("UPDATE verification_keys SET status = 'revoked' WHERE id = ? AND status = 'active'").run(id);
- };
- db.isUsernameReserved = async function (username) {
- const vk = await db.prepare("SELECT id FROM verification_keys WHERE lower(target_username) = lower(?) AND status = 'active'").get(username);
- return !!vk;
- };
-
Object.defineProperty(db, '_ovHelpers', { value: true, enumerable: false });
Object.defineProperty(db, '_ovHelpers', { value: true, enumerable: false });
return db;
return db;
}
}
deleted file mode 100644
deleted file mode 100644
@@ -1,818 +0,0 @@
@@ -1,818 +0,0 @@
-'use strict';
-
-// ═══════════════════════════════════════════════════════════════
-// Nginx Config Generator — Template-driven vhost generation
-// Replaces static hand-maintained nginx configs with generated
-// configs derived from the canonical URL registry.
-// ═══════════════════════════════════════════════════════════════
-
-const { execFile } = require('child_process');
-const fs = require('fs');
-const path = require('path');
-const { findBestCert } = require('./cert-manager');
-
-// ── Default Service Definitions ──────────────────────────────
-// These are the OpenVibe defaults. White-label installs override
-// via DEPLOY_SERVICE_MAP and DEPLOY_DOMAINS in the registry.
-
-const DEFAULT_SERVICE_MAP = {
- network: {
- port: 4000,
- domains: ['openvibe.network', 'my.openvibe.network'],
- maxBodySize: '5m',
- rateZones: [
- { name: 'openvibenetwork_api', rate: '10r/s' },
- { name: 'openvibenetwork_auth', rate: '5r/m' },
- { name: 'openvibenetwork_authorize', rate: '60r/m' },
- ],
- locations: [
- {
- // Every site's sign-in and silent check: no password here, so not the password endpoints' 5 a minute.
- match: '= /oauth/authorize',
- rateZone: 'openvibenetwork_authorize',
- rateBurst: 60,
- },
- {
- match: '~ ^/(api/auth/(register|login|anon-session)|oauth/token)',
- rateZone: 'openvibenetwork_auth',
- rateBurst: 5,
- },
- {
- // No cacheTime: the app sets Cache-Control on /shared/ (five minutes, a year for a
- // ?v= content-hash URL); expires/add_header here would send a second, conflicting one.
- match: '/shared/',
- },
- {
- match: '/api/',
- rateZone: 'openvibenetwork_api',
- rateBurst: 30,
- },
- {
- match: '/internal/',
- localhostOnly: true,
- },
- ],
- headers: {
- 'X-Content-Type-Options': 'nosniff',
- 'X-Frame-Options': 'SAMEORIGIN',
- 'Referrer-Policy': 'strict-origin-when-cross-origin',
- },
- },
- tools: {
- port: 4001,
- domains: ['openvibe.tools'],
- wildcardDomain: 'openvibe.tools',
- maxBodySize: '5m',
- rateZones: [
- { name: 'openvibetools_api', rate: '10r/s' },
- ],
- locations: [
- {
- match: '/api/',
- rateZone: 'openvibetools_api',
- rateBurst: 30,
- },
- ],
- headers: {
- 'X-Content-Type-Options': 'nosniff',
- 'X-Frame-Options': 'SAMEORIGIN',
- 'Referrer-Policy': 'strict-origin-when-cross-origin',
- },
- },
- media: {
- port: 4100,
- domains: ['openvibe.media'],
- maxBodySize: '500m',
- rateZones: [
- { name: 'openvibemedia_api', rate: '10r/s' },
- ],
- locations: [
- {
- match: '/api/',
- rateZone: 'openvibemedia_api',
- rateBurst: 40,
- readTimeout: '300s',
- sendTimeout: '300s',
- },
- ],
- headers: {
- 'X-Content-Type-Options': 'nosniff',
- 'X-Frame-Options': 'SAMEORIGIN',
- 'Referrer-Policy': 'strict-origin-when-cross-origin',
- },
- },
- live: {
- port: 3000,
- domains: ['openvibe.live', 'www.openvibe.live'],
- // WHIP ingest and the RTMP-info hostname are served by the same app.
- extraServerNames: ['whip.openvibe.live', 'ingest.openvibe.live'],
- wildcardDomain: 'openvibe.live',
- maxBodySize: '3m',
- rateZones: [
- { name: 'streamer_api', rate: '10r/s' },
- { name: 'streamer_login', rate: '5r/m' },
- ],
- // Two per-IP connection budgets: long-lived WebSockets (5-6 per open tab) must not use up the
- // budget a page load needs for its stylesheets and scripts. Mirrors OpenVibe.Live's
- // deploy/nginx/openvibe.live.conf, which is what production runs.
- connZones: ['addr_limit', 'asset_limit'],
- connLimit: { zone: 'addr_limit', n: 256 },
- // Static responses cached in front of Node; Node decides cacheability (content-hashed ?v=).
- proxyCaches: [{ path: '/var/cache/nginx/openvibe-live', zone: 'live_static', size: '20m', maxSize: '1g', inactive: '30d' }],
- gzip: true,
- // WebSocket URLs carry session tokens in the query string; keep them out of access logs.
- logWithoutQuery: true,
- // Wave 6: OpenVibe.Chat serves /ws/chat, /api/chat/, /api/dm/, /api/tts/ and /api/sounds.
- includes: ['/opt/openvibe.chat/deploy/nginx/*.locations.conf'],
- locations: [
- {
- match: '= /api/auth/register',
- rateZone: 'streamer_login',
- rateBurst: 5,
- },
- {
- match: '= /api/auth/login',
- rateZone: 'streamer_login',
- rateBurst: 10,
- },
- {
- match: '^~ /data/avatars/',
- cacheTime: '7d',
- },
- {
- match: '^~ /api/thumbnails/',
- cacheTime: '1h',
- },
- {
- match: '~ ^/api/vods/(upload|clips|stream/)',
- maxBodySize: '500m',
- readTimeout: '300s',
- sendTimeout: '300s',
- },
- {
- match: '/api/',
- rateZone: 'streamer_api',
- rateBurst: 40,
- websocket: true,
- maxBodySize: '10m',
- },
- {
- match: '/ws/',
- websocket: true,
- },
- {
- match: '~ ^/(?!api/|ws/|data/|media/).*\\.(?:css|js|mjs|map|woff2?|ttf|otf|svg|png|jpe?g|gif|ico|webp|mp3|wav)$',
- connLimit: { zone: 'asset_limit', n: 100 },
- proxyCache: 'live_static',
- },
- ],
- defaultLocationExtras: {
- websocket: true,
- headers: {
- 'Permissions-Policy': 'camera=*, microphone=*, display-capture=*',
- },
- },
- headers: {
- 'X-Content-Type-Options': 'nosniff',
- 'X-Frame-Options': 'SAMEORIGIN',
- 'Referrer-Policy': 'strict-origin-when-cross-origin',
- },
- },
- games: {
- port: 8000,
- domains: ['openvibe.games', 'www.openvibe.games', 'play.openvibe.games'],
- wildcardDomain: 'openvibe.games',
- maxBodySize: '2m',
- rateZones: [
- { name: 'openvibegames_api', rate: '10r/s' },
- { name: 'openvibegames_auth', rate: '5r/m' },
- ],
- locations: [
- {
- match: '/auth/',
- rateZone: 'openvibegames_auth',
- rateBurst: 5,
- },
- {
- match: '/api/',
- rateZone: 'openvibegames_api',
- rateBurst: 30,
- websocket: true,
- },
- ],
- headers: {
- 'X-Content-Type-Options': 'nosniff',
- 'X-Frame-Options': 'SAMEORIGIN',
- 'Referrer-Policy': 'strict-origin-when-cross-origin',
- },
- },
- maps: {
- port: 4010,
- domains: ['maps.openvibe.tools'],
- parentDomain: 'openvibe.tools',
- maxBodySize: '2m',
- rateZones: [
- { name: 'openvibemaps_api', rate: '10r/s' },
- ],
- locations: [
- {
- match: '= /api/search/stream',
- rateZone: 'openvibemaps_api',
- rateBurst: 10,
- sseStreaming: true,
- },
- {
- match: '/api/',
- rateZone: 'openvibemaps_api',
- rateBurst: 30,
- },
- ],
- headers: {
- 'X-Content-Type-Options': 'nosniff',
- 'X-Frame-Options': 'SAMEORIGIN',
- 'Referrer-Policy': 'strict-origin-when-cross-origin',
- },
- },
- food: {
- port: 4011,
- domains: ['food.openvibe.tools'],
- parentDomain: 'openvibe.tools',
- maxBodySize: '2m',
- rateZones: [
- { name: 'openvibefood_api', rate: '10r/s' },
- ],
- locations: [
- {
- match: '/api/',
- rateZone: 'openvibefood_api',
- rateBurst: 30,
- },
- ],
- headers: {
- 'X-Content-Type-Options': 'nosniff',
- 'X-Frame-Options': 'SAMEORIGIN',
- 'Referrer-Policy': 'strict-origin-when-cross-origin',
- },
- },
- img: {
- port: 4012,
- domains: ['img.openvibe.tools'],
- parentDomain: 'openvibe.tools',
- maxBodySize: '50m',
- rateZones: [
- { name: 'openvibeimg_api', rate: '5r/s' },
- ],
- locations: [
- {
- match: '/api/',
- rateZone: 'openvibeimg_api',
- rateBurst: 20,
- },
- ],
- headers: {
- 'X-Content-Type-Options': 'nosniff',
- 'X-Frame-Options': 'SAMEORIGIN',
- 'Referrer-Policy': 'strict-origin-when-cross-origin',
- },
- },
- docs: {
- port: 4016,
- domains: ['docs.openvibe.tools', 'pdf.openvibe.tools'],
- parentDomain: 'openvibe.tools',
- maxBodySize: '50m',
- rateZones: [
- { name: 'openvibedocs_api', rate: '5r/s' },
- ],
- locations: [
- {
- match: '/api/',
- rateZone: 'openvibedocs_api',
- rateBurst: 20,
- },
- ],
- headers: {
- 'X-Content-Type-Options': 'nosniff',
- 'X-Frame-Options': 'SAMEORIGIN',
- 'Referrer-Policy': 'strict-origin-when-cross-origin',
- },
- },
- yt: {
- port: 4013,
- domains: ['yt.openvibe.tools'],
- parentDomain: 'openvibe.tools',
- maxBodySize: '2m',
- rateZones: [
- { name: 'openvibeyt_api', rate: '2r/s' },
- ],
- locations: [
- {
- match: '~ ^/api/status/.+/stream$',
- sseStreaming: true,
- readTimeout: '600s',
- },
- {
- match: '~ ^/api/downloads/',
- readTimeout: '600s',
- },
- {
- match: '/api/',
- rateZone: 'openvibeyt_api',
- rateBurst: 10,
- },
- ],
- headers: {
- 'X-Content-Type-Options': 'nosniff',
- 'X-Frame-Options': 'SAMEORIGIN',
- 'Referrer-Policy': 'strict-origin-when-cross-origin',
- },
- },
- audio: {
- port: 4014,
- domains: ['audio.openvibe.tools'],
- parentDomain: 'openvibe.tools',
- maxBodySize: '100m',
- rateZones: [
- { name: 'openvibeaudio_api', rate: '5r/s' },
- ],
- locations: [
- {
- match: '/api/',
- rateZone: 'openvibeaudio_api',
- rateBurst: 20,
- },
- ],
- headers: {
- 'X-Content-Type-Options': 'nosniff',
- 'X-Frame-Options': 'SAMEORIGIN',
- 'Referrer-Policy': 'strict-origin-when-cross-origin',
- },
- },
- text: {
- port: 4015,
- domains: ['text.openvibe.tools'],
- parentDomain: 'openvibe.tools',
- maxBodySize: '2m',
- rateZones: [],
- locations: [
- {
- match: '/api/',
- },
- ],
- headers: {
- 'X-Content-Type-Options': 'nosniff',
- 'X-Frame-Options': 'SAMEORIGIN',
- 'Referrer-Policy': 'strict-origin-when-cross-origin',
- },
- },
-};
-
-// ── Nginx Config Generation ──────────────────────────────────
-
-function indent(text, level = 1) {
- const pad = ' '.repeat(level);
- return text.split('\n').map(l => l ? pad + l : '').join('\n');
-}
-
-function generateProxyBlock(port, loc = {}) {
- const lines = [];
- lines.push(`proxy_pass http://127.0.0.1:${port};`);
- lines.push('proxy_http_version 1.1;');
- lines.push('proxy_set_header Host $host;');
- // Client address headers come only from $remote_addr (realip: the visitor when the peer is a
- // Cloudflare edge, else the TCP peer). Appending to, or passing through, a client-sent
- // X-Forwarded-For / CF-Connecting-IP lets anyone reaching the origin directly (DNS-only
- // hosts, the bare IP) choose the address upstreams ban and rate-limit by.
- lines.push('proxy_set_header X-Real-IP $remote_addr;');
- lines.push('proxy_set_header X-Forwarded-For $remote_addr;');
- lines.push('proxy_set_header CF-Connecting-IP $remote_addr;');
- lines.push('proxy_set_header X-Forwarded-Proto $scheme;');
-
- if (loc.websocket) {
- lines.push('proxy_set_header Upgrade $http_upgrade;');
- lines.push('proxy_set_header Connection "upgrade";');
- }
-
- if (loc.sseStreaming) {
- lines.push("proxy_set_header Connection '';");
- lines.push('proxy_buffering off;');
- lines.push('proxy_cache off;');
- lines.push('chunked_transfer_encoding off;');
- }
-
- if (loc.readTimeout) {
- lines.push(`proxy_read_timeout ${loc.readTimeout};`);
- }
- if (loc.sendTimeout) {
- lines.push(`proxy_send_timeout ${loc.sendTimeout};`);
- }
-
- return lines.join('\n');
-}
-
-function generateLocationBlock(port, loc) {
- const lines = [];
-
- if (loc.rateZone) {
- lines.push(`limit_req zone=${loc.rateZone} burst=${loc.rateBurst || 10} nodelay;`);
- }
-
- if (loc.localhostOnly) {
- lines.push('allow 127.0.0.1;');
- lines.push('allow ::1;');
- lines.push('deny all;');
- }
-
- if (loc.maxBodySize) {
- lines.push(`client_max_body_size ${loc.maxBodySize};`);
- }
-
- if (loc.connLimit) {
- lines.push(`limit_conn ${loc.connLimit.zone} ${loc.connLimit.n};`);
- }
-
- if (loc.proxyCache) {
- // Only responses the app marks cacheable are stored (nginx honours Cache-Control).
- lines.push(`proxy_cache ${loc.proxyCache};`);
- lines.push('proxy_cache_key "$host$uri$is_args$args";');
- lines.push('proxy_cache_lock on;');
- lines.push('proxy_cache_use_stale error timeout updating http_502 http_503;');
- lines.push('add_header X-Cache $upstream_cache_status always;');
- }
-
- lines.push(generateProxyBlock(port, loc));
-
- if (loc.cacheTime) {
- lines.push(`expires ${loc.cacheTime};`);
- const seconds = parseDurationToSeconds(loc.cacheTime);
- lines.push(`add_header Cache-Control "public, max-age=${seconds}";`);
- }
-
- if (loc.headers) {
- for (const [k, v] of Object.entries(loc.headers)) {
- lines.push(`add_header ${k} "${v}" always;`);
- }
- }
-
- return `location ${loc.match} {\n${indent(lines.join('\n'))}\n}`;
-}
-
-function parseDurationToSeconds(dur) {
- const match = dur.match(/^(\d+)([smhd])$/);
- if (!match) return 3600;
- const num = parseInt(match[1]);
- switch (match[2]) {
- case 's': return num;
- case 'm': return num * 60;
- case 'h': return num * 3600;
- case 'd': return num * 86400;
- default: return 3600;
- }
-}
-
-/**
- * Generate a complete Nginx server block config for a service.
- *
- * @param {string} serviceId - Service identifier
- * @param {object} svc - Service config (from DEFAULT_SERVICE_MAP or registry)
- * @param {object} opts - Options
- * @param {string} opts.certPath - Path to SSL certificate
- * @param {string} opts.keyPath - Path to SSL private key
- * @param {boolean} opts.sslEnabled - Whether to generate HTTPS block
- * @returns {string} Nginx config text
- */
-function generateServiceConfig(serviceId, svc, opts = {}) {
- const lines = [];
- const allDomains = [...(svc.domains || []), ...(svc.extraServerNames || [])];
- if (svc.wildcardDomain && !svc.parentDomain) {
- // Add wildcard to server_name if this service owns the domain
- if (!allDomains.includes(`*.${svc.wildcardDomain}`)) {
- allDomains.push(`*.${svc.wildcardDomain}`);
- }
- }
- const serverNames = allDomains.join(' ');
-
- // Comment header
- lines.push(`# ═══════════════════════════════════════════════════════════════`);
- lines.push(`# ${serviceId} — Auto-generated Nginx Configuration`);
- lines.push(`# Generated by openvibe.network deploy system`);
- lines.push(`# DO NOT EDIT MANUALLY — managed via admin panel`);
- lines.push(`# ═══════════════════════════════════════════════════════════════`);
- lines.push('');
-
- // Rate limit zones
- if (svc.rateZones && svc.rateZones.length > 0) {
- for (const rz of svc.rateZones) {
- lines.push(`limit_req_zone $binary_remote_addr zone=${rz.name}:10m rate=${rz.rate};`);
- }
- lines.push('');
- }
-
- if (svc.logWithoutQuery) {
- lines.push(`log_format ${serviceId}_noquery '$remote_addr - $remote_user [$time_local] "$request_method $uri $server_protocol" $status $body_bytes_sent "$http_referer" "$http_user_agent"';`);
- }
- // Connection budgets and response caches
- for (const zone of svc.connZones || []) {
- lines.push(`limit_conn_zone $binary_remote_addr zone=${zone}:10m;`);
- }
- for (const c of svc.proxyCaches || []) {
- lines.push(`proxy_cache_path ${c.path} levels=1:2 keys_zone=${c.zone}:${c.size || '10m'} max_size=${c.maxSize || '512m'} inactive=${c.inactive || '7d'} use_temp_path=off;`);
- }
- if ((svc.connZones || []).length || (svc.proxyCaches || []).length) lines.push('');
-
- // HTTP → HTTPS redirect
- lines.push('# HTTP → HTTPS redirect');
- lines.push('server {');
- lines.push(' listen 80;');
- lines.push(' listen [::]:80;');
- lines.push(` server_name ${serverNames};`);
- lines.push(' return 301 https://$host$request_uri;');
- lines.push('}');
- lines.push('');
-
- // HTTPS server
- lines.push('# HTTPS server');
- lines.push('server {');
- lines.push(' listen 443 ssl;');
- lines.push(' listen [::]:443 ssl;');
- lines.push(' http2 on;');
- lines.push(` server_name ${serverNames};`);
- lines.push(' include snippets/security-txt.conf; # /.well-known/security.txt (the host\'s file; WS-R task 7)');
- lines.push('');
-
- // SSL certificate
- if (opts.sslEnabled !== false && opts.certPath && opts.keyPath) {
- lines.push(` ssl_certificate ${opts.certPath};`);
- lines.push(` ssl_certificate_key ${opts.keyPath};`);
- } else {
- lines.push(' # SSL certificate not configured — update via admin panel');
- lines.push(' # ssl_certificate /path/to/fullchain.pem;');
- lines.push(' # ssl_certificate_key /path/to/privkey.pem;');
- }
- lines.push('');
-
- // Logs
- const logName = svc.domains?.[0]?.replace(/\./g, '-') || serviceId;
- lines.push(` access_log /var/log/nginx/${logName}.access.log${svc.logWithoutQuery ? ` ${serviceId}_noquery` : ''};`);
- lines.push(` error_log /var/log/nginx/${logName}.error.log;`);
- lines.push('');
-
- // Body size
- lines.push(` client_max_body_size ${svc.maxBodySize || '5m'};`);
- lines.push('');
-
- if (svc.connLimit) {
- lines.push(` limit_conn ${svc.connLimit.zone} ${svc.connLimit.n};`);
- lines.push('');
- }
-
- if (svc.gzip) {
- // Compress text on the hop to the CDN too; the global config does not compress proxied responses.
- lines.push(' gzip on;');
- lines.push(' gzip_proxied any;');
- lines.push(' gzip_vary on;');
- lines.push(' gzip_comp_level 5;');
- lines.push(' gzip_min_length 1024;');
- lines.push(' gzip_types text/css application/javascript text/javascript application/json image/svg+xml text/plain application/xml text/xml;');
- lines.push('');
- }
-
- // Security headers
- if (svc.headers) {
- for (const [k, v] of Object.entries(svc.headers)) {
- lines.push(` add_header ${k} ${v} always;`);
- }
- lines.push('');
- }
-
- // Location files other services ship for this host (e.g. OpenVibe.Chat's chat paths on
- // openvibe.live). A glob include is a no-op when the service is not installed.
- for (const inc of svc.includes || []) {
- lines.push(` include ${inc};`);
- lines.push('');
- }
-
- // Location blocks
- if (svc.locations && svc.locations.length > 0) {
- for (const loc of svc.locations) {
- lines.push(indent(generateLocationBlock(svc.port, loc)));
- lines.push('');
- }
- }
-
- // Default location (catch-all)
- const defaultLoc = svc.defaultLocationExtras || {};
- const defaultLines = [];
- defaultLines.push(generateProxyBlock(svc.port, defaultLoc));
- if (defaultLoc.headers) {
- for (const [k, v] of Object.entries(defaultLoc.headers)) {
- defaultLines.push(`add_header ${k} "${v}" always;`);
- }
- }
- lines.push(indent(`location / {\n${indent(defaultLines.join('\n'))}\n}`));
- lines.push('');
-
- // Deny dotfiles (except .well-known)
- lines.push(indent('location ~ /\\.(?!well-known) {'));
- lines.push(indent(' deny all;'));
- lines.push(indent('}'));
- lines.push('}');
-
- return lines.join('\n');
-}
-
-/**
- * Generate all Nginx configs for the entire network.
- *
- * @param {object} registry - Resolved URL registry
- * @param {object} opts - Options
- * @returns {Array<{serviceId: string, filename: string, content: string}>}
- */
-function generateAllConfigs(registry = {}) {
- const serviceMap = registry.DEPLOY_SERVICE_MAP?.value || DEFAULT_SERVICE_MAP;
- const deployDomains = registry.DEPLOY_DOMAINS?.value || [];
-
- // Build domain → cert mapping
- const domainCertMap = {};
- for (const dd of deployDomains) {
- if (dd.domain && dd.certName) {
- domainCertMap[dd.domain] = dd.certName;
- }
- }
-
- const configs = [];
-
- for (const [serviceId, svc] of Object.entries(serviceMap)) {
- // Determine which cert to use
- const certDomain = svc.parentDomain || svc.wildcardDomain || svc.domains?.[0];
- const certName = domainCertMap[certDomain] || certDomain;
- const cert = certName ? findBestCert(certName) : null;
-
- const filename = (svc.domains?.[0] || serviceId).replace(/\./g, '-') + '.conf';
- const content = generateServiceConfig(serviceId, svc, {
- sslEnabled: !!cert,
- certPath: cert?.fullchain || null,
- keyPath: cert?.privkey || null,
- });
-
- configs.push({ serviceId, filename, content, certFound: !!cert });
- }
-
- return configs;
-}
-
-// ── Nginx Apply / Reload ─────────────────────────────────────
-
-/**
- * Test nginx configuration syntax.
- * @returns {Promise<{ok: boolean, output: string}>}
- */
-async function testNginxConfig() {
- return new Promise((resolve) => {
- execFile('nginx', ['-t'], { timeout: 10_000 }, (err, stdout, stderr) => {
- const output = ((stdout || '') + '\n' + (stderr || '')).trim();
- if (err) {
- resolve({ ok: false, output, error: err.message });
- } else {
- resolve({ ok: true, output });
- }
- });
- });
-}
-
-/**
- * Reload nginx.
- * @returns {Promise<{ok: boolean, output: string}>}
- */
-async function reloadNginx() {
- return new Promise((resolve) => {
- execFile('systemctl', ['reload', 'nginx'], { timeout: 10_000 }, (err, stdout, stderr) => {
- const output = ((stdout || '') + '\n' + (stderr || '')).trim();
- if (err) {
- resolve({ ok: false, output, error: err.message });
- } else {
- resolve({ ok: true, output });
- }
- });
- });
-}
-
-/**
- * Apply generated configs to the Nginx sites directory.
- * Creates backups, writes new configs, validates, and optionally reloads.
- *
- * @param {Array} configs - Output from generateAllConfigs()
- * @param {object} opts
- * @param {string} opts.sitesPath - Target directory (default: /etc/nginx/sites-enabled)
- * @param {string} opts.backupPath - Backup directory (default: /etc/nginx/sites-backup)
- * @param {boolean} opts.reload - Whether to reload nginx after apply
- * @param {boolean} opts.dryRun - Only validate, don't write
- * @returns {Promise<{ok: boolean, applied: string[], backed_up: string[], validation: object, reload: object}>}
- */
-async function applyConfigs(configs, opts = {}) {
- const sitesPath = opts.sitesPath || '/etc/nginx/sites-enabled';
- const backupPath = opts.backupPath || '/etc/nginx/sites-backup';
- const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
- const backupDir = path.join(backupPath, timestamp);
-
- const result = {
- ok: false,
- dryRun: !!opts.dryRun,
- applied: [],
- backed_up: [],
- validation: null,
- reload: null,
- errors: [],
- };
-
- if (opts.dryRun) {
- // In dry-run mode, validate configs by writing to a temp dir
- const tmpDir = path.join(require('os').tmpdir(), `nginx-preview-${timestamp}`);
- try {
- fs.mkdirSync(tmpDir, { recursive: true });
- for (const cfg of configs) {
- fs.writeFileSync(path.join(tmpDir, cfg.filename), cfg.content);
- result.applied.push(cfg.filename);
- }
- result.ok = true;
- result.validation = { ok: true, output: 'Dry run — configs generated but not applied' };
- } catch (err) {
- result.errors.push(err.message);
- } finally {
- try { fs.rmSync(tmpDir, { recursive: true }); } catch { /* ignore */ }
- }
- return result;
- }
-
- // Create backup directory
- try {
- fs.mkdirSync(backupDir, { recursive: true });
- } catch (err) {
- result.errors.push(`Cannot create backup dir: ${err.message}`);
- return result;
- }
-
- // Backup existing configs
- try {
- const existing = fs.readdirSync(sitesPath).filter(f => f.endsWith('.conf') || !f.includes('.'));
- for (const f of existing) {
- const src = path.join(sitesPath, f);
- // Resolve symlinks for backup
- const realSrc = fs.existsSync(src) ? (fs.lstatSync(src).isSymbolicLink() ? fs.readlinkSync(src) : src) : src;
- try {
- const content = fs.readFileSync(realSrc, 'utf8');
- fs.writeFileSync(path.join(backupDir, f), content);
- result.backed_up.push(f);
- } catch { /* skip unreadable files */ }
- }
- } catch (err) {
- result.errors.push(`Backup failed: ${err.message}`);
- return result;
- }
-
- // Write new configs
- for (const cfg of configs) {
- try {
- fs.writeFileSync(path.join(sitesPath, cfg.filename), cfg.content);
- result.applied.push(cfg.filename);
- } catch (err) {
- result.errors.push(`Write ${cfg.filename}: ${err.message}`);
- }
- }
-
- // Validate
- result.validation = await testNginxConfig();
- if (!result.validation.ok) {
- // Rollback — restore from backup
- result.errors.push('Nginx validation failed — rolling back');
- for (const f of result.backed_up) {
- try {
- const backup = fs.readFileSync(path.join(backupDir, f), 'utf8');
- fs.writeFileSync(path.join(sitesPath, f), backup);
- } catch { /* best effort */ }
- }
- // Remove newly written files that weren't in backup
- for (const f of result.applied) {
- if (!result.backed_up.includes(f)) {
- try { fs.unlinkSync(path.join(sitesPath, f)); } catch { /* ignore */ }
- }
- }
- return result;
- }
-
- // Reload if requested
- if (opts.reload) {
- result.reload = await reloadNginx();
- }
-
- result.ok = result.validation.ok && (!opts.reload || result.reload?.ok);
- return result;
-}
-
-module.exports = {
- DEFAULT_SERVICE_MAP,
- generateServiceConfig,
- generateAllConfigs,
- testNginxConfig,
- reloadNginx,
- applyConfigs,
-};
deleted file mode 100644
deleted file mode 100644
@@ -1,450 +0,0 @@
@@ -1,450 +0,0 @@
-'use strict';
-
-// ═══════════════════════════════════════════════════════════════
-// Deploy Routes — Admin API for TLS / Nginx / Infrastructure
-// Mounted at /api/admin/deploy. Requires admin role.
-// ═══════════════════════════════════════════════════════════════
-
-const express = require('express');
-const certManager = require('./cert-manager');
-const nginxGenerator = require('./nginx-generator');
-const urlRegistry = require('../url-registry');
-const { URL_DEFINITIONS } = require('openvibe-shared/url-resolver');
-
-module.exports = function createDeployRoutes(db, requireAuth) {
- const router = express.Router();
-
- // Deploy touches TLS, nginx and live infrastructure — owner-only.
- const OWNER = (process.env.OWNER_USERNAME || 'goosely').toLowerCase();
- function requireAdmin(req, res, next) {
- const isOwner = req.user && req.user.username && req.user.username.toLowerCase() === OWNER;
- if (!req.user || req.user.role !== 'admin' || !isOwner) {
- return res.status(403).json({ ok: false, error: 'Owner access required' });
- }
- next();
- }
-
- async function auditLog(req, action, details) {
- try {
- await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
- req.user.id, action, JSON.stringify(details)
- );
- } catch (err) {
- console.error('[Deploy] Audit log error:', err.message);
- }
- }
-
- async function getDeployConfig() {
- const resolved = await urlRegistry.getResolvedRegistry(db, process.env);
- return {
- acmeEmail: resolved.DEPLOY_ACME_EMAIL?.value || '',
- certMode: resolved.DEPLOY_CERT_MODE?.value || 'manual',
- cloudflareToken: resolved.DEPLOY_CLOUDFLARE_TOKEN?.value || '',
- domains: resolved.DEPLOY_DOMAINS?.value || [],
- nginxMode: resolved.DEPLOY_NGINX_MODE?.value || 'preview',
- nginxSitesPath: resolved.DEPLOY_NGINX_SITES_PATH?.value || '/etc/nginx/sites-enabled',
- nginxBackupPath: resolved.DEPLOY_NGINX_BACKUP_PATH?.value || '/etc/nginx/sites-backup',
- serviceMap: resolved.DEPLOY_SERVICE_MAP?.value || null,
- };
- }
-
- router.use(requireAuth, requireAdmin);
-
- // ═══════════════════════════════════════════════════════
- // System Prerequisites
- // ═══════════════════════════════════════════════════════
-
- router.get('/prerequisites', async (req, res) => {
- try {
- const prereqs = certManager.checkPrerequisites();
- const config = await getDeployConfig();
- res.json({
- ok: true,
- prerequisites: prereqs,
- config: {
- acmeEmail: config.acmeEmail,
- certMode: config.certMode,
- hasCloudflareToken: !!config.cloudflareToken,
- nginxMode: config.nginxMode,
- domainsConfigured: config.domains.length,
- },
- });
- } catch (err) {
- res.status(500).json({ ok: false, error: err.message });
- }
- });
-
- // ═══════════════════════════════════════════════════════
- // Deploy Configuration
- // ═══════════════════════════════════════════════════════
-
- router.get('/config', async (req, res) => {
- try {
- const config = await getDeployConfig();
- // Mask the Cloudflare token
- const masked = { ...config };
- if (masked.cloudflareToken && masked.cloudflareToken.length > 4) {
- masked.cloudflareToken = '••••' + masked.cloudflareToken.slice(-4);
- }
- res.json({ ok: true, config: masked });
- } catch (err) {
- res.status(500).json({ ok: false, error: err.message });
- }
- });
-
- router.put('/config', async (req, res) => {
- try {
- const allowedKeys = {
- acmeEmail: 'DEPLOY_ACME_EMAIL',
- certMode: 'DEPLOY_CERT_MODE',
- cloudflareToken: 'DEPLOY_CLOUDFLARE_TOKEN',
- domains: 'DEPLOY_DOMAINS',
- nginxMode: 'DEPLOY_NGINX_MODE',
- nginxSitesPath: 'DEPLOY_NGINX_SITES_PATH',
- nginxBackupPath: 'DEPLOY_NGINX_BACKUP_PATH',
- serviceMap: 'DEPLOY_SERVICE_MAP',
- };
-
- const updated = {};
- for (const [bodyKey, registryKey] of Object.entries(allowedKeys)) {
- if (!(bodyKey in req.body)) continue;
- const value = req.body[bodyKey];
- // Skip masked secrets
- if (registryKey === 'DEPLOY_CLOUDFLARE_TOKEN' && typeof value === 'string' && value.startsWith('••••')) {
- continue;
- }
- // Validate cert mode
- if (registryKey === 'DEPLOY_CERT_MODE' && !['cloudflare', 'manual', 'none'].includes(value)) {
- return res.status(400).json({ ok: false, error: 'certMode must be "cloudflare", "manual", or "none"' });
- }
- // Validate nginx mode
- if (registryKey === 'DEPLOY_NGINX_MODE' && !['preview', 'apply', 'disabled'].includes(value)) {
- return res.status(400).json({ ok: false, error: 'nginxMode must be "preview", "apply", or "disabled"' });
- }
- const entry = await urlRegistry.setRegistryEntry(db, registryKey, value, req.user.id);
- updated[bodyKey] = registryKey === 'DEPLOY_CLOUDFLARE_TOKEN' ? '(saved)' : entry.value;
- }
-
- await auditLog(req, 'deploy_config_update', { keys: Object.keys(updated) });
- res.json({ ok: true, updated });
- } catch (err) {
- res.status(400).json({ ok: false, error: err.message });
- }
- });
-
- // ═══════════════════════════════════════════════════════
- // Certificate Status
- // ═══════════════════════════════════════════════════════
-
- router.get('/certs', async (req, res) => {
- try {
- const status = await certManager.getCertificateStatus();
- const config = await getDeployConfig();
-
- // Annotate certs with domain config
- for (const cert of status.certs) {
- const matchingDomain = config.domains.find(d =>
- cert.domains?.includes(d.domain) || cert.domains?.includes(`*.${d.domain}`)
- );
- if (matchingDomain) {
- cert.managedDomain = matchingDomain;
- }
- }
-
- res.json({ ok: true, ...status });
- } catch (err) {
- res.status(500).json({ ok: false, error: err.message });
- }
- });
-
- // ═══════════════════════════════════════════════════════
- // Certificate Issuance — Cloudflare Mode
- // ═══════════════════════════════════════════════════════
-
- router.post('/certs/issue-cloudflare', async (req, res) => {
- try {
- const { domain } = req.body;
- if (!domain) return res.status(400).json({ ok: false, error: 'domain is required' });
-
- const config = await getDeployConfig();
- if (!config.acmeEmail) {
- return res.status(400).json({ ok: false, error: 'ACME email not configured. Set it in Deploy Config.' });
- }
- if (!config.cloudflareToken) {
- return res.status(400).json({ ok: false, error: 'Cloudflare token not configured. Set it in Deploy Config.' });
- }
-
- await auditLog(req, 'cert_issue_cloudflare_start', { domain });
-
- const result = await certManager.issueWildcardCloudflare({
- domain,
- email: config.acmeEmail,
- cloudflareToken: config.cloudflareToken,
- });
-
- await auditLog(req, 'cert_issue_cloudflare_result', {
- domain,
- ok: result.ok,
- certName: result.certName,
- error: result.error,
- });
-
- if (result.ok) {
- // Update domain config with cert name
- const domains = config.domains.map(d => {
- if (d.domain === domain) return { ...d, certName: result.certName };
- return d;
- });
- // If domain not in list, add it
- if (!domains.find(d => d.domain === domain)) {
- domains.push({ domain, wildcard: true, certName: result.certName, services: [] });
- }
- await urlRegistry.setRegistryEntry(db, 'DEPLOY_DOMAINS', domains, req.user.id);
- }
-
- res.json({ ok: result.ok, ...result });
- } catch (err) {
- res.status(500).json({ ok: false, error: err.message });
- }
- });
-
- // ═══════════════════════════════════════════════════════
- // Certificate Issuance — Manual DNS Mode
- // ═══════════════════════════════════════════════════════
-
- router.post('/certs/manual-info', async (req, res) => {
- try {
- const { domain } = req.body;
- if (!domain) return res.status(400).json({ ok: false, error: 'domain is required' });
-
- const config = await getDeployConfig();
- if (!config.acmeEmail) {
- return res.status(400).json({ ok: false, error: 'ACME email not configured' });
- }
-
- const result = await certManager.getManualChallengeInfo({
- domain,
- email: config.acmeEmail,
- });
-
- res.json({ ok: true, ...result });
- } catch (err) {
- res.status(500).json({ ok: false, error: err.message });
- }
- });
-
- router.post('/certs/issue-manual', async (req, res) => {
- try {
- const { domain } = req.body;
- if (!domain) return res.status(400).json({ ok: false, error: 'domain is required' });
-
- const config = await getDeployConfig();
- if (!config.acmeEmail) {
- return res.status(400).json({ ok: false, error: 'ACME email not configured' });
- }
-
- await auditLog(req, 'cert_issue_manual_start', { domain });
-
- const result = await certManager.issueWildcardManual({
- domain,
- email: config.acmeEmail,
- });
-
- await auditLog(req, 'cert_issue_manual_result', {
- domain,
- ok: result.ok,
- certName: result.certName,
- error: result.error,
- });
-
- if (result.ok) {
- const domains = config.domains.map(d => {
- if (d.domain === domain) return { ...d, certName: result.certName };
- return d;
- });
- if (!domains.find(d => d.domain === domain)) {
- domains.push({ domain, wildcard: true, certName: result.certName, services: [] });
- }
- await urlRegistry.setRegistryEntry(db, 'DEPLOY_DOMAINS', domains, req.user.id);
- }
-
- res.json({ ok: result.ok, ...result });
- } catch (err) {
- res.status(500).json({ ok: false, error: err.message });
- }
- });
-
- // ═══════════════════════════════════════════════════════
- // Certificate Renewal
- // ═══════════════════════════════════════════════════════
-
- router.post('/certs/renew', async (req, res) => {
- try {
- await auditLog(req, 'cert_renew_start', {});
- const result = await certManager.renewCertificates();
- await auditLog(req, 'cert_renew_result', { ok: result.ok, error: result.error });
- res.json({ ok: result.ok, ...result });
- } catch (err) {
- res.status(500).json({ ok: false, error: err.message });
- }
- });
-
- // ═══════════════════════════════════════════════════════
- // Nginx Config Preview
- // ═══════════════════════════════════════════════════════
-
- router.get('/nginx/preview', async (req, res) => {
- try {
- const resolved = await urlRegistry.getResolvedRegistry(db, process.env);
- const configs = nginxGenerator.generateAllConfigs(resolved);
- res.json({
- ok: true,
- configs: configs.map(c => ({
- serviceId: c.serviceId,
- filename: c.filename,
- content: c.content,
- certFound: c.certFound,
- })),
- });
- } catch (err) {
- res.status(500).json({ ok: false, error: err.message });
- }
- });
-
- router.get('/nginx/preview/:serviceId', async (req, res) => {
- try {
- const resolved = await urlRegistry.getResolvedRegistry(db, process.env);
- const configs = nginxGenerator.generateAllConfigs(resolved);
- const cfg = configs.find(c => c.serviceId === req.params.serviceId);
- if (!cfg) return res.status(404).json({ ok: false, error: 'Service not found' });
- res.json({ ok: true, ...cfg });
- } catch (err) {
- res.status(500).json({ ok: false, error: err.message });
- }
- });
-
- // ═══════════════════════════════════════════════════════
- // Nginx Validate
- // ═══════════════════════════════════════════════════════
-
- router.post('/nginx/validate', async (req, res) => {
- try {
- const result = await nginxGenerator.testNginxConfig();
- res.json({ ok: result.ok, ...result });
- } catch (err) {
- res.status(500).json({ ok: false, error: err.message });
- }
- });
-
- // ═══════════════════════════════════════════════════════
- // Nginx Apply (write + validate + optional reload)
- // ═══════════════════════════════════════════════════════
-
- router.post('/nginx/apply', async (req, res) => {
- try {
- const config = await getDeployConfig();
- if (config.nginxMode === 'disabled') {
- return res.status(400).json({ ok: false, error: 'Nginx management is disabled' });
- }
-
- const dryRun = config.nginxMode === 'preview' || req.body.dryRun === true;
- const reload = req.body.reload !== false && !dryRun;
-
- await auditLog(req, 'nginx_apply_start', { dryRun, reload });
-
- const resolved = await urlRegistry.getResolvedRegistry(db, process.env);
- const configs = nginxGenerator.generateAllConfigs(resolved);
-
- const result = await nginxGenerator.applyConfigs(configs, {
- sitesPath: config.nginxSitesPath,
- backupPath: config.nginxBackupPath,
- reload,
- dryRun,
- });
-
- await auditLog(req, 'nginx_apply_result', {
- ok: result.ok,
- dryRun: result.dryRun,
- applied: result.applied,
- errors: result.errors,
- });
-
- res.json({ ok: result.ok, ...result });
- } catch (err) {
- res.status(500).json({ ok: false, error: err.message });
- }
- });
-
- // ═══════════════════════════════════════════════════════
- // Nginx Reload (standalone)
- // ═══════════════════════════════════════════════════════
-
- router.post('/nginx/reload', async (req, res) => {
- try {
- // Validate first
- const validation = await nginxGenerator.testNginxConfig();
- if (!validation.ok) {
- return res.status(400).json({
- ok: false,
- error: 'Nginx config validation failed — will not reload',
- validation,
- });
- }
-
- await auditLog(req, 'nginx_reload', {});
- const result = await nginxGenerator.reloadNginx();
- res.json({ ok: result.ok, ...result, validation });
- } catch (err) {
- res.status(500).json({ ok: false, error: err.message });
- }
- });
-
- // ═══════════════════════════════════════════════════════
- // Managed Domains CRUD
- // ═══════════════════════════════════════════════════════
-
- router.get('/domains', async (req, res) => {
- try {
- const config = await getDeployConfig();
- // Annotate each domain with cert status
- const domains = (config.domains || []).map(d => {
- const cert = certManager.findBestCert(d.certName || d.domain);
- return {
- ...d,
- certExists: !!cert,
- certPath: cert?.fullchain || null,
- keyPath: cert?.privkey || null,
- certName: cert?.certName || d.certName || d.domain,
- };
- });
- res.json({ ok: true, domains });
- } catch (err) {
- res.status(500).json({ ok: false, error: err.message });
- }
- });
-
- router.put('/domains', async (req, res) => {
- try {
- const { domains } = req.body;
- if (!Array.isArray(domains)) {
- return res.status(400).json({ ok: false, error: 'domains must be an array' });
- }
- // Validate each domain entry
- for (const d of domains) {
- if (!d.domain || typeof d.domain !== 'string') {
- return res.status(400).json({ ok: false, error: 'Each domain entry must have a "domain" string' });
- }
- if (!/^[a-z0-9.-]+\.[a-z]{2,}$/.test(d.domain)) {
- return res.status(400).json({ ok: false, error: `Invalid domain: ${d.domain}` });
- }
- }
- const entry = await urlRegistry.setRegistryEntry(db, 'DEPLOY_DOMAINS', domains, req.user.id);
- await auditLog(req, 'deploy_domains_update', { count: domains.length });
- res.json({ ok: true, domains: entry.value });
- } catch (err) {
- res.status(400).json({ ok: false, error: err.message });
- }
- });
-
- return router;
-};
@@ -35,7 +35,6 @@ const createSetupRoutes = require('./setup/routes');
@@ -35,7 +35,6 @@ const createSetupRoutes = require('./setup/routes');
const networkAnalytics = require('./analytics/network'); // ADR-021: no IP/user id, route templates, 30-day raw retention
const networkAnalytics = require('./analytics/network'); // ADR-021: no IP/user id, route templates, 30-day raw retention
const { DiscordService } = require('./discord/discord-service');
const { DiscordService } = require('./discord/discord-service');
const createDiscordRoutes = require('./discord/routes');
const createDiscordRoutes = require('./discord/routes');
-const createDeployRoutes = require('./deploy/routes');
const createCoinsRoutes = require('./coins/routes');
const createCoinsRoutes = require('./coins/routes');
const createWalletRoutes = require('./wallet/routes');
const createWalletRoutes = require('./wallet/routes');
const { signToken } = require('./auth/routes');
const { signToken } = require('./auth/routes');
@@ -1073,26 +1072,9 @@ app.use('/api/domains', rateLimit({ windowMs: 60_000, max: 120 }), toolDomains.p
@@ -1073,26 +1072,9 @@ app.use('/api/domains', rateLimit({ windowMs: 60_000, max: 120 }), toolDomains.p
app.use('/api/admin/domains', toolDomains.adminRouter);
app.use('/api/admin/domains', toolDomains.adminRouter);
app.use('/api/admin/discord', createDiscordRoutes(db, discordService, requireAuth, requireAdmin));
app.use('/api/admin/discord', createDiscordRoutes(db, discordService, requireAuth, requireAdmin));
-// Deploy (TLS / Nginx / Infrastructure) admin API
-app.use('/api/admin/deploy', createDeployRoutes(db, requireAuth));
// Registry operators (plan T2): cell and instance route weights, draining, node principal status; staff session only.
// Registry operators (plan T2): cell and instance route weights, draining, node principal status; staff session only.
app.use('/api/admin/registry', require('./registry/registry-admin').createRegistryAdmin(db, requireAuth, requireAdmin));
app.use('/api/admin/registry', require('./registry/registry-admin').createRegistryAdmin(db, requireAuth, requireAdmin));
-// SSH access provisioning info — powers the admin "SSH" tab.
-// Returns non-secret connection context (host, project roots, services). The
-// actual host lives in env (SSH_SERVER_HOST) so no infra detail is committed.
-app.get('/api/admin/ssh-info', requireAuth, requireAdmin, (req, res) => {
- const OWNER = (process.env.OWNER_USERNAME || 'goosely').toLowerCase();
- res.json({
- ok: true,
- host: process.env.SSH_SERVER_HOST || '',
- ownerUsername: OWNER,
- projectsRoot: process.env.SSH_PROJECTS_ROOT || '/opt/openvibe.network /opt/openvibe.live',
- services: ['openvibe-network', 'openvibe-live'],
- is_owner: !!(req.user.username && req.user.username.toLowerCase() === OWNER),
- });
-});
-
// Setup API for first-run bootstrapping and status checks
// Setup API for first-run bootstrapping and status checks
app.use('/api/setup', createSetupRoutes(db, config));
app.use('/api/setup', createSetupRoutes(db, config));
@@ -12,7 +12,7 @@ for (const [, body] of html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/g)) as
@@ -12,7 +12,7 @@ for (const [, body] of html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/g)) as
const files = fs.readdirSync(path.join(pub, 'js', 'admin')).filter((f) => f.endsWith('.js')).sort();
const files = fs.readdirSync(path.join(pub, 'js', 'admin')).filter((f) => f.endsWith('.js')).sort();
// The module map, in load order (public/js/admin/…).
// The module map, in load order (public/js/admin/…).
-const modules = ['core.js', 'dashboard.js', 'users.js', 'moderation.js', 'settings.js', 'tools-workers.js', 'payments.js', 'glossary.js', 'ai.js', 'ai-setup.js', 'guide-rules.js', 'guide.js', 'inference.js', 'setup.js', 'content.js', 'analytics.js', 'deploy.js', 'boot.js'];
+const modules = ['core.js', 'dashboard.js', 'users.js', 'moderation.js', 'settings.js', 'tools-workers.js', 'payments.js', 'glossary.js', 'ai.js', 'ai-setup.js', 'guide-rules.js', 'guide.js', 'inference.js', 'setup.js', 'content.js', 'analytics.js', 'boot.js'];
assert.deepStrictEqual(files, [...modules].sort(), 'each module file exists and there is no other');
assert.deepStrictEqual(files, [...modules].sort(), 'each module file exists and there is no other');
const tags = [...html.matchAll(/<script\b([^>]*)\bsrc="\/js\/admin\/([^"]+)"([^>]*)><\/script>/g)];
const tags = [...html.matchAll(/<script\b([^>]*)\bsrc="\/js\/admin\/([^"]+)"([^>]*)><\/script>/g)];
const loaded = tags.map((m) => m[2]);
const loaded = tags.map((m) => m[2]);
new file mode 100644
new file mode 100644
@@ -0,0 +1,29 @@
@@ -0,0 +1,29 @@
+'use strict';
+// Removed on purpose (the owner, 2026-11-13): account reservation keys ("no one needs it … no remnants"), the Deploy tab
+// and the SSH tab ("security risk … get rid of it"; deploys are ovhost's and changes go through git.openvibe.codes, Codes
+// and the agents), and the Glossary tab (its words open in place, beside the word). Nothing of them is left to call.
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const read = (p) => fs.readFileSync(path.join(__dirname, '..', p), 'utf8');
+const exists = (p) => fs.existsSync(path.join(__dirname, '..', p));
+
+const html = read('public/admin.html');
+for (const tab of ['vkeys', 'deploy', 'ssh', 'glossary']) {
+ assert.ok(!html.includes(`showTab('${tab}'`) && !html.includes(`id="sec-${tab}"`), `no ${tab} tab or section`);
+}
+assert.ok(!/js\/admin\/deploy\.js/.test(html), 'no Deploy script');
+for (const f of ['public/js/admin/deploy.js', 'server/deploy/routes.js', 'server/deploy/nginx-generator.js']) assert.ok(!exists(f), `${f} is gone`);
+
+const index = read('server/index.js');
+assert.ok(!/api\/admin\/deploy|ssh-info|createDeployRoutes/.test(index), 'no Deploy or SSH route');
+const admin = read('server/admin/routes.js');
+assert.ok(!/verification-keys/.test(admin), 'no reservation key routes');
+const auth = read('server/auth/routes.js');
+assert.ok(!/verification_key|isUsernameReserved|redeemVerificationKey/.test(auth), 'sign-up takes no claim code');
+const db = read('server/db/database.js');
+assert.ok(!/VerificationKey|isUsernameReserved/.test(db), 'no reservation key helpers');
+assert.ok(!/claim-code|verification_key/.test(read('public/login.html') + read('public/js/login.js')), 'the sign-up form has no claim code');
+const core = read('public/js/admin/core.js');
+assert.ok(!/deployCurrentSubTab|showDeploySubTab|'deploy'/.test(core), 'no Deploy routing in the panel');
+console.log('admin-removed-1113: reservation keys, Deploy, SSH and the Glossary tab are gone, with nothing left to call');
@@ -46,7 +46,7 @@ for (const id of ['credits', 'byok', 'resale', 'cache', 'quota', 'workflow']) as
@@ -46,7 +46,7 @@ for (const id of ['credits', 'byok', 'resale', 'cache', 'quota', 'workflow']) as
// A machine word may appear only quoted, as the name the owner will meet elsewhere ("KEK" in the env file).
// A machine word may appear only quoted, as the name the owner will meet elsewhere ("KEK" in the env file).
for (const g of S.GLOSSARY) assert.ok(g.word && g.text.length >= 60 && !/\b(KEK|circuit|stub|Mtok)\b/.test(g.text.replace(/"[^"]*"/g, '')), `${g.id} is explained in plain words`);
for (const g of S.GLOSSARY) assert.ok(g.word && g.text.length >= 60 && !/\b(KEK|circuit|stub|Mtok)\b/.test(g.text.replace(/"[^"]*"/g, '')), `${g.id} is explained in plain words`);
assert.strictEqual(new Set(ids).size, ids.length, 'each word once');
assert.strictEqual(new Set(ids).size, ids.length, 'each word once');
-assert.strictEqual(S.glossaryLink('credits'), '<a class="ov-what" href="/admin#glossary/credits" data-glossary="credits">What does this mean?</a>');
+assert.strictEqual(S.glossaryLink('credits'), '<a class="ov-what" href="#" role="button" data-glossary="credits">What does this mean?</a>');
assert.strictEqual(S.glossaryLink('nope'), '', 'no link for a word the glossary does not have');
assert.strictEqual(S.glossaryLink('nope'), '', 'no link for a word the glossary does not have');
// ── Setup is an owner-only tab (public/js/admin/core.js) ──
// ── Setup is an owner-only tab (public/js/admin/core.js) ──
deleted file mode 100644
deleted file mode 100644
@@ -1,38 +0,0 @@
@@ -1,38 +0,0 @@
-/**
- * The generated openvibe.live config must carry what production's hand-maintained config
- * (OpenVibe.Live deploy/nginx/openvibe.live.conf) depends on, or regenerating it from the admin
- * panel silently drops WHIP ingest, the connection budgets, the static cache and compression.
- *
- * node test/nginx-generator-live.test.js
- */
-'use strict';
-const assert = require('assert');
-const g = require('../server/deploy/nginx-generator');
-
-const conf = g.generateServiceConfig('live', g.DEFAULT_SERVICE_MAP.live, { sslEnabled: true, certPath: '/c/f.pem', keyPath: '/c/k.pem' });
-const must = [
- ['WHIP hostname', /server_name [^;]*whip\.openvibe\.live/],
- ['ingest hostname', /server_name [^;]*ingest\.openvibe\.live/],
- ['socket connection budget', /limit_conn_zone \$binary_remote_addr zone=addr_limit:10m;[\s\S]*limit_conn addr_limit 256;/],
- ['asset connection budget', /limit_conn_zone \$binary_remote_addr zone=asset_limit:10m;[\s\S]*limit_conn asset_limit 100;/],
- ['static cache zone', /proxy_cache_path \/var\/cache\/nginx\/openvibe-live [^;]*keys_zone=live_static:/],
- ['static cache on the asset location', /location ~ \^\/\(\?!api\/\|ws\/\|data\/\|media\/\)[^{]*\{[^}]*proxy_cache live_static;/],
- ['gzip for proxied text', /gzip_proxied any;/],
- ['upload body size', /location ~ \^\/api\/vods\/\(upload\|clips\|stream\/\) \{[^}]*client_max_body_size 500m;/],
- ['websocket upgrade on catch-all', /location \/ \{[^}]*proxy_set_header Upgrade \$http_upgrade;/],
- ['client address from realip only', /proxy_set_header CF-Connecting-IP \$remote_addr;/],
- ['no client-sent X-Forwarded-For appended', /^(?![\s\S]*proxy_add_x_forwarded_for)/],
- ['access log without query strings (tokens)', /log_format live_noquery [^;]*\$uri[^;]*;[\s\S]*access_log [^;]* live_noquery;/],
- ['capture permissions header', /Permissions-Policy "camera=\*, microphone=\*, display-capture=\*"/],
- ['OpenVibe.Chat locations (glob, before /api/)', /include \/opt\/openvibe\.chat\/deploy\/nginx\/\*\.locations\.conf;[\s\S]*location \/api\/ \{/],
-];
-for (const [name, re] of must) assert.ok(re.test(conf), `generated live config is missing: ${name}`);
-// The asset regex must not swallow /api/ or /ws/ (a regex location beats prefix locations).
-assert.ok(!/location ~ \^\/\.\*\\\.\(\?:css/.test(conf), 'asset location must exclude /api/ and /ws/');
-// The Network's /shared/ location must leave Cache-Control to the app (five minutes, or immutable
-// for a ?v= content-hash URL); an expires/add_header there would send a second, conflicting one.
-const net = g.generateServiceConfig('network', g.DEFAULT_SERVICE_MAP.network, { sslEnabled: true, certPath: '/c/f.pem', keyPath: '/c/k.pem' });
-const sharedLoc = net.match(/location \/shared\/ \{[^}]*\}/);
-assert.ok(sharedLoc, 'generated network config is missing location /shared/');
-assert.ok(!/expires|Cache-Control/.test(sharedLoc[0]), 'network /shared/ must not set its own cache headers');
-console.log(`nginx generator (live): ${must.length} checks passed; network /shared/ leaves caching to the app`);
@@ -152,7 +152,6 @@ function secretsNeedlesFor(w, people) {
@@ -152,7 +152,6 @@ function secretsNeedlesFor(w, people) {
return (who) => {
return (who) => {
const n = { ...w.secrets };
const n = { ...w.secrets };
for (const [other, h] of Object.entries(people)) if (other !== who && tokenOf(h)) n[`${other}'s bearer token`] = tokenOf(h);
for (const [other, h] of Object.entries(people)) if (other !== who && tokenOf(h)) n[`${other}'s bearer token`] = tokenOf(h);
- if (who !== 'admin' && who !== 'owner') n['verification key'] = w.verificationKey;
return n;
return n;
};
};
}
}
@@ -163,15 +162,14 @@ function secretsPaths(w) {
@@ -163,15 +162,14 @@ function secretsPaths(w) {
'/data/avatars/%2e%2e/network.db', '/shared/..%2f..%2fpackage.json', '/node_modules/.package-lock.json', '/api/.well-known/jwks',
'/data/avatars/%2e%2e/network.db', '/shared/..%2f..%2fpackage.json', '/node_modules/.package-lock.json', '/api/.well-known/jwks',
'/.well-known/openid-configuration', '/oauth/.well-known/openid-configuration', '/api/ready', '/metrics', '/release.json', '/status',
'/.well-known/openid-configuration', '/oauth/.well-known/openid-configuration', '/api/ready', '/metrics', '/release.json', '/status',
'/api/v1/status', '/api/admin/secrets', '/api/admin/settings', '/api/admin/config', '/api/admin/url-registry', '/api/admin/email',
'/api/v1/status', '/api/admin/secrets', '/api/admin/settings', '/api/admin/config', '/api/admin/url-registry', '/api/admin/email',
- '/api/admin/discord/', '/api/admin/integrations/github/', '/api/admin/deploy/config', '/internal/url-registry/resolved',
+ '/api/admin/discord/', '/api/admin/integrations/github/', '/internal/url-registry/resolved',
'/internal/integrations/github-token', '/api/setup/status', '/oauth/authorize?client_id=live&redirect_uri=https%3A%2F%2Fevil.test%2F&response_type=code',
'/internal/integrations/github-token', '/api/setup/status', '/oauth/authorize?client_id=live&redirect_uri=https%3A%2F%2Fevil.test%2F&response_type=code',
'/oauth/client-info?client_id=live', `/oauth/client-info?client_id=${w.dev.PA.app}`, '/api/auth/anon-identities', '/sso/check', '/fedcm/accounts'];
'/oauth/client-info?client_id=live', `/oauth/client-info?client_id=${w.dev.PA.app}`, '/api/auth/anon-identities', '/sso/check', '/fedcm/accounts'];
return crawler.pathsFor(w.routes, (name) => secretsValues(w, name), { method: 'get', query: 'limit=5&all=1&debug=1&include=secret', extra });
return crawler.pathsFor(w.routes, (name) => secretsValues(w, name), { method: 'get', query: 'limit=5&all=1&debug=1&include=secret', extra });
}
}
async function checkSecretsStored(w, needlesFor) {
async function checkSecretsStored(w, needlesFor) {
- const all = { ...needlesFor('nobody'), 'verification key': w.verificationKey };
- delete all['verification key']; // stored in its own table by design; only its readers are checked above
+ const all = { ...needlesFor('nobody') };
const logHits = crawler.leaks({ text: w.srv.logs(), headers: {} }, all).map((l) => l.label);
const logHits = crawler.leaks({ text: w.srv.logs(), headers: {} }, all).map((l) => l.label);
assert.deepStrictEqual(logHits, [], 'nothing secret was logged');
assert.deepStrictEqual(logHits, [], 'nothing secret was logged');
const tables = (await w.db.prepare("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema() AND (table_name LIKE '%outbox%' OR table_name LIKE '%audit%' OR table_name LIKE '%log%' OR table_name LIKE '%usage%' OR table_name LIKE '%changes%' OR table_name LIKE '%alerts%' OR table_name LIKE '%revisions%' OR table_name = 'notifications')").all()).map((r) => r.name);
const tables = (await w.db.prepare("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema() AND (table_name LIKE '%outbox%' OR table_name LIKE '%audit%' OR table_name LIKE '%log%' OR table_name LIKE '%usage%' OR table_name LIKE '%changes%' OR table_name LIKE '%alerts%' OR table_name LIKE '%revisions%' OR table_name = 'notifications')").all()).map((r) => r.name);
@@ -10,7 +10,7 @@
@@ -10,7 +10,7 @@
* secrets overlap) and bob's PB (app AB); an export token minted for PA
* secrets overlap) and bob's PB (app AB); an export token minted for PA
* per-person data: alice's modules, blocks, follows, notifications, sessions, linked account,
* per-person data: alice's modules, blocks, follows, notifications, sessions, linked account,
* history, push subscription, refresh token (OAuth code flow); an anonymous identity;
* history, push subscription, refresh token (OAuth code flow); an anonymous identity;
- * provider-secret copies in site_settings; a verification key; a closed old incident
+ * provider-secret copies in site_settings; a closed old incident
* `secrets` collects every secret value with a label, for the suites to look for in responses.
* `secrets` collects every secret value with a label, for the suites to look for in responses.
* Not a test itself (no .test.js).
* Not a test itself (no .test.js).
*/
*/
@@ -199,8 +199,6 @@ async function buildWorld({ label = 'sec', env: extraEnv = {} } = {}) {
@@ -199,8 +199,6 @@ async function buildWorld({ label = 'sec', env: extraEnv = {} } = {}) {
await db.prepare("INSERT INTO site_settings (key, value, type) VALUES (?, ?, 'secret') ON CONFLICT (key) DO UPDATE SET value = excluded.value, type = excluded.type").run(k, v);
await db.prepare("INSERT INTO site_settings (key, value, type) VALUES (?, ?, 'secret') ON CONFLICT (key) DO UPDATE SET value = excluded.value, type = excluded.type").run(k, v);
secrets[`db setting ${k}`] = v;
secrets[`db setting ${k}`] = v;
}
}
- const vk = `VK-${crypto.randomBytes(8).toString('hex')}`;
- await db.prepare('INSERT INTO verification_keys (key, target_username, created_by) VALUES (?, ?, ?)').run(vk, 'carol', rows.staff.id);
// A per-user password hash is never shown to anyone.
// A per-user password hash is never shown to anyone.
for (const u of ['alice', 'bob', 'staff']) { await db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(`$2a$10$${u}${crypto.randomBytes(20).toString('hex')}`, users[u].id); secrets[`${u} password hash`] = (await db.prepare('SELECT password_hash FROM users WHERE id = ?').get(users[u].id)).password_hash; }
for (const u of ['alice', 'bob', 'staff']) { await db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(`$2a$10$${u}${crypto.randomBytes(20).toString('hex')}`, users[u].id); secrets[`${u} password hash`] = (await db.prepare('SELECT password_hash FROM users WHERE id = ?').get(users[u].id)).password_hash; }
@@ -214,7 +212,7 @@ async function buildWorld({ label = 'sec', env: extraEnv = {} } = {}) {
@@ -214,7 +212,7 @@ async function buildWorld({ label = 'sec', env: extraEnv = {} } = {}) {
};
};
return {
return {
dir: srv.dir, srv, db, keys, vapid, issuer: ISSUER, routes, sentinels, secrets, users, dev, callers, clientSecrets,
dir: srv.dir, srv, db, keys, vapid, issuer: ISSUER, routes, sentinels, secrets, users, dev, callers, clientSecrets,
- liveToken, appToken, anonToken, verificationKey: vk, pushSub, shownOnce, call, serviceToken, sign, S,
+ liveToken, appToken, anonToken, pushSub, shownOnce, call, serviceToken, sign, S,
// Every TCP connection the server made so far, as 'host port' lines (the preload logs them).
// Every TCP connection the server made so far, as 'host port' lines (the preload logs them).
connects: () => { try { return fs.readFileSync(dump.OV_CONNECT_LOG, 'utf8').split('\n').filter(Boolean); } catch { return []; } },
connects: () => { try { return fs.readFileSync(dump.OV_CONNECT_LOG, 'utf8').split('\n').filter(Boolean); } catch { return []; } },
stop: async () => { await srv.stop(); try { fs.rmSync(dir, { recursive: true, force: true }); fs.rmSync(srv.dir, { recursive: true, force: true }); } catch { /* */ } },
stop: async () => { await srv.stop(); try { fs.rmSync(dir, { recursive: true, force: true }); fs.rmSync(srv.dir, { recursive: true, force: true }); } catch { /* */ } },
@@ -137,7 +137,7 @@ const marker = (v) => ({ redacted: true, fingerprint: fp(v) });
@@ -137,7 +137,7 @@ const marker = (v) => ({ redacted: true, fingerprint: fp(v) });
const firstTab = html.slice(html.indexOf('id="main-tabs"')).match(/<button[^>]*>/)[0];
const firstTab = html.slice(html.indexOf('id="main-tabs"')).match(/<button[^>]*>/)[0];
assert.match(firstTab, /showTab\('setup',this\)/, 'Setup is the first tab');
assert.match(firstTab, /showTab\('setup',this\)/, 'Setup is the first tab');
assert.match(firstTab, /owner-only/, 'the Setup button is hidden from anyone but the owner');
assert.match(firstTab, /owner-only/, 'the Setup button is hidden from anyone but the owner');
- assert.ok(html.includes('id="sec-setup"') && html.includes('id="sec-glossary"'));
+ assert.ok(html.includes('id="sec-setup"') && !html.includes('id="sec-glossary"'));
const core = fs.readFileSync(path.join(ROOT, 'public', 'js', 'admin', 'core.js'), 'utf8');
const core = fs.readFileSync(path.join(ROOT, 'public', 'js', 'admin', 'core.js'), 'utf8');
assert.match(core.match(/const OWNER_ONLY_TABS = new Set\(\[[^\]]*\]\)/)[0], /'setup'/, 'a non-owner who opens /admin#setup is sent to the dashboard');
assert.match(core.match(/const OWNER_ONLY_TABS = new Set\(\[[^\]]*\]\)/)[0], /'setup'/, 'a non-owner who opens /admin#setup is sent to the dashboard');