openvibers/openvibe.network Commits
The owner's Setup tab, and the AI tab in plain words (#610)
Changes
@@@ -20,6 +20,7 @@ Identity and account service for the OpenVibe network. Manages user accounts, OA- **Theme Catalog** — Shared theme system with ~30 built-in themes and community submissions. Theme preferences sync across all services.- **Per-site theme overrides** — one theme follows a person to every site, and an override is their exception for one service ("Games in Neon Tokyo", "Live in Daylight"). `GET /api/themes/me/active?service=<id>` answers that service's theme (`override: true`, with the `service` named) or the person's global theme (`override: false`); a missing or unknown service answers exactly as before. The person owns their own list at `GET /api/themes/me/overrides`, `PUT /api/themes/me/overrides/:service` and `DELETE /api/themes/me/overrides/:service` (the theme and the custom colours are validated as `PUT /api/themes/me` does), at most 50 per person, and every change is kept in the theme history with the service recorded.- **Admin Panel** — email configuration, user management (role changes, bans), broadcast notifications, system health dashboard, and audit log.++- **Setup** (`/admin#setup`, owner only) — one checklist of everything only the owner can give the network: keys, accounts, caps and a few registrations. It lists every field a service's config namespace marks `x-owner-input` (openvibe-shared 4.125.0), reached through the owner settings proxy (`server/admin/ai-config-proxy.js`, seventeen services), Needed first, then Not working, Connected and Optional, with a counted summary. A key is written once through the proxy (fresh sign-in, the service's vault, audit, alert) and never shown again; **Test** asks the service to try it and keeps the one-line answer; Network's own to-dos (legal registrations, DNSSEC at the registrar, a top-up) are its `network.setup` namespace with **Mark done** (`server/admin/setup.js`). The AI tab and every admin page explain their words; `/admin#glossary` lists them.- **Reviewer queue** (`/admin/review`) — one staff-only page for every Wiki page, Blog draft, AI brief and Trade company page waiting for a person, grouped by site with the publishing gate's noindex reason in words. Approve (indexable) or keep hidden calls the owning service's own review route and writes its own review log.- **Internal API** — Server-to-server endpoints for token verification, user lookup, notification push, account linking, and audit logging. Every route takes a capability-scoped service token; the deployment-wide key they used to accept was retired on 2026-09-29 (plan T2, [docs/retirement.md](docs/retirement.md)) and opens nothing.- **Developer projects (foundation)** — projects, members, apps (OAuth clients with `app_` subjects), hashed client secrets with rotation and revocation, capability grants within a staff-set allowance (sandbox apps also get a default sandbox allowance of public Media, Events-app and Tools-job capabilities, so a new project works without staff), recorded quotas, per-project usage from the services' hourly rollups (`GET /api/v1/projects/:project/usage`) and an append-only audit at `/api/v1/projects`. Its events can be relayed to OpenVibe.Events through an outbox when `OV_EVENTS_INTERNAL_URL` is set. Bearer user tokens only; the console over this API is OpenVibe.Services at openvibe.services (it moved there from OpenVibe.Codes on 2026-10-08). See [docs/developer-projects.md](docs/developer-projects.md).@@@ -47,6 +47,7 @@</button><div class="section-tabs-wrap"><div class="section-tabs" id="main-tabs">++ <button class="admin-tab-btn owner-only" onclick="showTab('setup',this)"><i class="fa-solid fa-list-check"></i> Setup</button><button class="admin-tab-btn active" onclick="showTab('dashboard',this)"><i class="fa-solid fa-chart-line"></i> Dashboard</button><button class="admin-tab-btn" onclick="location.href='/admin/review'"><i class="fa-solid fa-list-check"></i> Review queue</button><button class="admin-tab-btn" onclick="showTab('users',this)"><i class="fa-solid fa-users"></i> Users</button>@@@ -75,6 +76,7 @@<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">@@@ -82,6 +84,16 @@</button></div>++ <!-- Setup (owner only): everything only the owner can give the network, from every service (js/admin/setup.js) -->++ <div class="section" id="sec-setup">++ <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" id="sec-dashboard"><div class="stats-grid" id="dashboard-stats"><div class="loading">Loading stats...</div></div>@@@ -647,7 +659,9 @@<script src="/js/admin/moderation.js" defer></script><script src="/js/admin/settings.js" defer></script><script src="/js/admin/payments.js" defer></script>++<script src="/js/admin/glossary.js" defer></script><script src="/js/admin/ai.js" defer></script>++<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>@@@ -192,21 +192,21 @@ a.btn{text-decoration:none}.pai-provider-meta{display:flex;flex-wrap:wrap;align-items:center;gap:7px;margin-top:5px;font-size:11.5px;color:var(--text-muted)}.pai-provider-meta code{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:11px;color:var(--text-secondary)}.pai-access{font-size:10px;font-weight:700;padding:2px 7px;border-radius:999px;border:1px solid transparent}--.pai-access.is-own_deployment{color:var(--success);background:color-mix(in srgb,var(--success) 15%,transparent);border-color:color-mix(in srgb,var(--success) 35%,transparent)}++.pai-access.is-own_deployment{color:var(--success-text,var(--success));background:color-mix(in srgb,var(--success) 15%,transparent);border-color:color-mix(in srgb,var(--success) 35%,transparent)}.pai-access.is-authorized_wholesale{color:var(--accent-light);background:color-mix(in srgb,var(--accent) 16%,transparent);border-color:color-mix(in srgb,var(--accent) 38%,transparent)}.pai-access.is-customer_byok{color:#a78bfa;background:color-mix(in srgb,#8b5cf6 17%,transparent);border-color:color-mix(in srgb,#8b5cf6 40%,transparent)}--.pai-access.is-restricted{color:var(--warning);background:color-mix(in srgb,var(--warning) 14%,transparent);border-color:color-mix(in srgb,var(--warning) 38%,transparent)}++.pai-access.is-restricted{color:var(--warning-text,var(--warning));background:color-mix(in srgb,var(--warning) 14%,transparent);border-color:color-mix(in srgb,var(--warning) 38%,transparent)}.pai-access.is-unverified{color:var(--text-muted);background:color-mix(in srgb,var(--text-muted) 14%,transparent);border-color:color-mix(in srgb,var(--text-muted) 32%,transparent)}.pai-billing{font-size:11px;color:var(--text-secondary)}.pai-keystate{margin-top:5px;font-size:11.5px;font-family:ui-monospace,Menlo,Consolas,monospace}--.pai-keystate.is-ok{color:var(--success)}++.pai-keystate.is-ok{color:var(--success-text,var(--success))}.pai-keystate.is-muted{color:var(--text-muted)}--.pai-keystate.is-bad{color:var(--danger)}++.pai-keystate.is-bad{color:var(--danger-text,var(--danger))}.pai-provider-actions{display:flex;flex-wrap:wrap;align-items:center;justify-content:flex-end;gap:6px;max-width:430px}.pai .btn.pai-quiet{background:transparent;border:1px solid var(--border);color:var(--text-secondary)}.pai .btn.pai-quiet:hover{border-color:var(--accent);color:var(--text)}--.pai .btn.pai-quiet.pai-quiet-danger{color:var(--danger);border-color:color-mix(in srgb,var(--danger) 45%,var(--border));background:transparent}--.pai .btn.pai-quiet.pai-quiet-danger:hover{background:color-mix(in srgb,var(--danger) 10%,transparent);border-color:var(--danger);color:var(--danger)}++.pai .btn.pai-quiet.pai-quiet-danger{color:var(--danger-text,var(--danger));border-color:color-mix(in srgb,var(--danger) 45%,var(--border));background:transparent}++.pai .btn.pai-quiet.pai-quiet-danger:hover{background:color-mix(in srgb,var(--danger) 10%,transparent);border-color:var(--danger);color:var(--danger-text,var(--danger))}.pai-switch{display:inline-flex;align-items:center;gap:8px;font-size:12px;color:var(--text-secondary);min-height:34px;padding-right:4px;cursor:pointer;user-select:none}.pai-switch input{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}.pai-track{position:relative;display:inline-block;width:36px;height:20px;flex:0 0 36px;border-radius:10px;background:var(--border);border:1px solid var(--border);transition:background-color .16s ease,border-color .16s ease}@@@ -233,16 +233,16 @@ a.btn{text-decoration:none}.pai-field-desc+.pai-control{margin-top:6px}.pai-chip-lock,.pai-chip-danger,.pai-chip-secret{font-size:10px;font-weight:700;padding:2px 7px;border-radius:999px;border:1px solid transparent}.pai-chip-lock{color:var(--text-muted);background:var(--bg-tertiary);border-color:var(--border)}--.pai-chip-danger{color:var(--warning);background:color-mix(in srgb,var(--warning) 15%,transparent);border-color:color-mix(in srgb,var(--warning) 40%,transparent)}++.pai-chip-danger{color:var(--warning-text,var(--warning));background:color-mix(in srgb,var(--warning) 15%,transparent);border-color:color-mix(in srgb,var(--warning) 40%,transparent)}.pai-chip-secret{color:#a78bfa;background:color-mix(in srgb,#8b5cf6 14%,transparent);border-color:color-mix(in srgb,#8b5cf6 36%,transparent)}.pai-pin-line{font-size:11px;color:var(--text-muted);margin-top:2px}--.pai-marker{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12px;color:var(--success);padding:7px 0;display:inline-block}++.pai-marker{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12px;color:var(--success-text,var(--success));padding:7px 0;display:inline-block}.pai-secret{display:flex;align-items:center;gap:7px;flex-wrap:wrap}.pai-chips{display:flex;flex-wrap:wrap;gap:5px;min-height:26px}.pai-chip{display:inline-flex;align-items:center;gap:4px;padding:4px 8px;border-radius:999px;background:var(--bg-tertiary);border:1px solid var(--border);font-size:11.5px;color:var(--text-secondary);max-width:100%}.pai-chip>span{overflow-wrap:anywhere}.pai-chip button{border:0;background:none;color:var(--text-muted);cursor:pointer;font-size:14px;line-height:1;padding:2px 3px}--.pai-chip button:hover{color:var(--danger)}++.pai-chip button:hover{color:var(--danger-text,var(--danger))}.pai-list-add{display:flex;gap:6px;align-items:center}.pai-list-add input{flex:1;min-width:0}.pai-money-input,.pai-unit-input{display:flex;align-items:center;gap:0;position:relative}@@@ -256,8 +256,8 @@ a.btn{text-decoration:none}.pai-vault-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px;padding:10px 12px;background:var(--bg-card);border:1px solid var(--border);border-radius:9px}.pai-vault-name{font-size:13px;font-weight:700;min-width:140px}.pai-state{font-size:11.5px;font-weight:700;padding:2px 8px;border-radius:999px}--.pai-state.is-ok{color:var(--success);background:color-mix(in srgb,var(--success) 15%,transparent)}--.pai-state.is-bad{color:var(--danger);background:color-mix(in srgb,var(--danger) 15%,transparent)}++.pai-state.is-ok{color:var(--success-text,var(--success));background:color-mix(in srgb,var(--success) 15%,transparent)}++.pai-state.is-bad{color:var(--danger-text,var(--danger));background:color-mix(in srgb,var(--danger) 15%,transparent)}.pai-state.is-muted{color:var(--text-muted);background:color-mix(in srgb,var(--text-muted) 14%,transparent)}.pai-vault-row code{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:11px;color:var(--text-secondary)}.pai-hint{flex-basis:100%;font-size:11px;color:var(--text-muted)}@@@ -279,3 +279,99 @@ a.btn{text-decoration:none}.pai-table td{padding:7px 8px;border-bottom:1px solid var(--border);vertical-align:top;overflow-wrap:anywhere}@media(max-width:800px){.pai-provider{grid-template-columns:minmax(0,1fr)}.pai-provider-actions{justify-content:flex-start;max-width:none}.pai-fields{grid-template-columns:minmax(0,1fr)}}@media(max-width:640px){.pai-form-row{grid-template-columns:minmax(0,1fr)}#platform-ai .btn,#platform-ai input,#platform-ai select,#platform-ai textarea,#platform-ai summary,#platform-ai .pai-index a,#platform-ai .pai-switch{min-height:44px}#platform-ai .pai-chip button{min-width:44px;min-height:44px}.pai-index{gap:2px;padding:5px}.pai-index a{padding:8px 9px;font-size:11.5px}.pai-section{scroll-margin-top:96px}.pai-dialog{width:calc(100vw - 16px)}.pai-form{padding:14px}.pai-card{padding:13px}}++++/* ── The clear admin panel (2026-11-07): machine keys small and grey, Advanced settings folds, forms for objects ── */++.pai-mkey{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:10.5px;font-weight:500;color:var(--text-muted);background:var(--bg-tertiary);padding:1px 6px;border-radius:5px;vertical-align:middle;overflow-wrap:anywhere}++.pai-words{font-size:12px;margin-top:4px}++.pai-advanced{margin-top:14px;border-top:1px solid var(--border);padding-top:8px}++.pai-advanced>summary{cursor:pointer;font-size:12.5px;font-weight:700;color:var(--accent-light);min-height:34px;display:flex;align-items:center}++.pai-subform{display:grid;gap:10px;padding:10px;border:1px solid var(--border);border-radius:9px;background:var(--bg-secondary)}++.pai-subfield{display:grid;gap:4px;min-width:0}++.pai-subfield-label{font-size:12px;font-weight:600;display:flex;flex-wrap:wrap;gap:6px;align-items:center}++.pai-subfield-label code{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:10.5px;font-weight:500;color:var(--text-muted)}++.pai-unit-line{font-size:11px}++.ov-what{font-size:11.5px;font-weight:600;color:var(--accent-light);text-decoration:underline;text-underline-offset:2px;white-space:nowrap}++.ov-what:hover,.ov-what:focus-visible{color:var(--text)}++.ov-what-text{font-size:13.5px;line-height:1.6;color:var(--text)}++++/* ── Setup (owner only): one checklist, Needed first ─────────────────────────────────────────────────────── */++.setup{max-width:1100px;min-width:0;overflow-wrap:anywhere}++.setup-head{margin-bottom:18px}++.setup-head h2{font-size:20px;font-weight:800;display:flex;align-items:center;gap:9px;margin-bottom:6px}++.setup-head>p{max-width:820px;font-size:13px;line-height:1.55}++.setup-summary{margin:12px 0 6px;padding:10px 14px;border-radius:10px;background:var(--bg-card);border:1px solid var(--border);font-size:14px;color:var(--text)}++.setup-help{font-size:12px;margin:6px 0 10px}++.setup-group{margin:0 0 22px;scroll-margin-top:64px}++.setup-group>h3{font-size:15px;font-weight:800;margin-bottom:8px;display:flex;flex-wrap:wrap;align-items:center;gap:8px}++.setup-count{font-size:11px;font-weight:700;padding:2px 8px;border-radius:999px}++.setup-count.is-bad{color:var(--danger-text,var(--danger));background:color-mix(in srgb,var(--danger) 14%,transparent)}++.setup-count.is-warn{color:var(--warning-text,var(--warning));background:color-mix(in srgb,var(--warning) 15%,transparent)}++.setup-rows{list-style:none;display:grid;gap:8px}++.setup-row{display:grid;grid-template-columns:minmax(0,1fr) minmax(240px,340px);gap:10px 18px;align-items:start;padding:13px 14px;background:var(--bg-card);border:1px solid var(--border);border-left-width:4px;border-radius:10px}++.setup-row.is-needed{border-left-color:var(--danger)}++.setup-row.is-not_working{border-left-color:var(--warning)}++.setup-row.is-connected{border-left-color:var(--success)}++.setup-row.is-optional{border-left-color:var(--border)}++.setup-row-main{min-width:0}++.setup-row-title{display:flex;flex-wrap:wrap;align-items:center;gap:8px;font-size:14px}++.setup-state{font-size:11px;font-weight:700;padding:2px 8px;border-radius:999px}++.setup-state.is-bad{color:var(--danger-text,var(--danger));background:color-mix(in srgb,var(--danger) 15%,transparent)}++.setup-state.is-warn{color:var(--warning-text,var(--warning));background:color-mix(in srgb,var(--warning) 16%,transparent)}++.setup-state.is-ok{color:var(--success-text,var(--success));background:color-mix(in srgb,var(--success) 15%,transparent)}++.setup-state.is-muted{color:var(--text-muted);background:color-mix(in srgb,var(--text-muted) 14%,transparent)}++.setup-what{font-size:12.5px;line-height:1.5;color:var(--text-secondary);margin-top:4px}++.setup-state-line{font-size:12px;line-height:1.5;margin-top:5px}++.setup-state-line.is-bad{color:var(--danger-text,var(--danger))}++.setup-state-line.is-warn{color:var(--warning-text,var(--warning))}++.setup-state-line.is-ok{color:var(--success-text,var(--success))}++.setup-state-line.is-muted{color:var(--text-muted)}++.setup-where{font-size:12px;line-height:1.5;margin-top:5px;color:var(--text-secondary)}++.setup-where a{color:var(--accent-light);font-weight:600}++.setup-from{font-size:11px;color:var(--text-muted);margin-top:6px;display:flex;flex-wrap:wrap;gap:5px;align-items:center}++.setup-from code{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:10.5px;color:var(--text-muted);background:var(--bg-tertiary);padding:1px 6px;border-radius:5px;overflow-wrap:anywhere}++.setup-row-actions{display:grid;gap:8px;min-width:0}++.setup-row-actions>.btn{justify-self:start}++.setup-input{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px;align-items:end;min-width:0}++.setup-input-label{grid-column:1/-1;font-size:11.5px;font-weight:600;color:var(--text-secondary)}++.setup-input input:not([type=checkbox]){width:100%;min-width:0;min-height:36px;padding:7px 9px;background:var(--bg-input);border:1px solid var(--border);border-radius:7px;color:var(--text);font-size:13px;font-family:inherit}++.setup-input input[type=checkbox]{width:20px;height:20px}++.setup-more-actions{display:flex;flex-wrap:wrap;gap:6px}++.setup .btn.pai-quiet{background:transparent;border:1px solid var(--border);color:var(--text-secondary)}++.setup .btn.pai-quiet:hover{border-color:var(--accent);color:var(--text)}++.setup .btn.pai-quiet.pai-quiet-danger{color:var(--danger-text,var(--danger));border-color:color-mix(in srgb,var(--danger) 45%,var(--border))}++.setup-pinned{font-size:12px}++.setup-result{grid-column:1/-1;font-size:12.5px;line-height:1.5;padding:8px 10px;border-radius:8px;background:var(--bg-secondary);border:1px solid var(--border)}++.setup-result[hidden]{display:none}++.setup-result.is-ok{color:var(--success-text,var(--success))}++.setup-result.is-bad{color:var(--danger-text,var(--danger))}++.setup-result.is-muted{color:var(--text-muted)}++.setup-empty,.setup-foot{font-size:12.5px;line-height:1.55;margin:4px 0 12px;max-width:820px}++.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-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}++ .setup-where{display:grid;gap:0}++ .setup-where a{display:flex;align-items:center;gap:6px;min-height:44px;width:fit-content}++ .setup-input{grid-template-columns:minmax(0,1fr)}++ .setup-input .btn{justify-content:center}++ .setup-more-actions .btn{flex:1 1 auto;justify-content:center}++ .setup-row{padding:12px}++}@@@ -11,6 +11,23 @@ const AI_KEYS = ['ai_enabled','ai_max_cost_usd_per_day','ai_viewers_enabled', 'ai_viewers_max_roster', 'ai_viewers_max_lines_per_min', 'ai_viewers_global_cap_usd_per_day', 'ai_viewers_default_settings_json'];++// Live's AI settings in plain words; the machine key is shown small and grey after each title, never instead of it.++const AI_KEY_TITLES = {++ ai_enabled: 'AI on OpenVibe.Live',++ ai_paste_analysis_enabled: 'Describe new pastes',++ ai_stream_memory_enabled: 'Remember what happens on streams',++ ai_stream_capture_interval_sec: 'Seconds between stream snapshots',++ ai_transcription_enabled: 'Write down what streamers say',++ ai_timeline_enabled: 'Build a timeline of each stream',++ ai_max_cost_usd_per_day: 'Most Live may spend on AI in a day (dollars)',++ ai_viewers_enabled: 'AI chat viewers',++ ai_viewers_max_roster: 'Most AI viewers in one channel',++ ai_viewers_max_lines_per_min: 'Most AI viewer messages a minute',++ ai_viewers_global_cap_usd_per_day: 'Most AI viewers may spend in a day on OpenVibe\'s key (dollars)',++ ai_viewers_default_settings_json: 'AI viewers\' starting settings (advanced)',++};++const aiKeyTitle = (key) => AI_KEY_TITLES[key] || (typeof paiHumanize === 'function' ? paiHumanize(key) : key);++// ── The staff room (OpenVibe.Chat /r/staff): one room for all of staff, with Vibe and a shared work queue ──const CHAT_ORIGIN = 'https://openvibe.chat';const TASK_STATE = { queued: 'Waiting', running: 'Working', succeeded: 'Done', failed: 'Stuck', cancelled: 'Cancelled' };@@@ -65,18 +82,18 @@ async function loadAi() {// Server redacts API keys / money settings for non-owner admins — show locked.if (s.redacted) return `<div class="setting-row" style="flex-direction:column;align-items:stretch">-- <label><strong>${esc(key)}</strong> <span style="color:var(--muted);font-size:11px;font-weight:600">🔒 Owner only</span><br><small>${esc(s.description||'')}</small></label>++ <label><strong>${esc(aiKeyTitle(key))}</strong> <code class="pai-mkey">${esc(key)}</code> <span style="color:var(--muted);font-size:11px;font-weight:600">🔒 Owner only</span><br><small>${esc(s.description||'')}</small></label><input type="text" value="${esc(s.value)}" disabled data-redacted="1"style="margin-top:4px;padding:8px 10px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--muted);font-size:13px;opacity:0.65"></div>`;if (s.type === 'boolean') return `-- <div class="setting-row"><label><strong>${esc(key)}</strong><br><small>${esc(s.description||'')}</small></label>++ <div class="setting-row"><label><strong>${esc(aiKeyTitle(key))}</strong> <code class="pai-mkey">${esc(key)}</code><br><small>${esc(s.description||'')}</small></label><input type="checkbox" data-key="${esc(key)}" data-type="boolean" ${s.value==='true'?'checked':''} style="width:18px;height:18px;cursor:pointer"></div>`;if (s.type === 'json' || /_json$/.test(key)) return `<div class="setting-row" style="flex-direction:column;align-items:stretch">-- <label><strong>${esc(key)}</strong><br><small>${esc(s.description||'')}</small></label>++ <label><strong>${esc(aiKeyTitle(key))}</strong> <code class="pai-mkey">${esc(key)}</code><br><small>${esc(s.description||'')}</small></label><textarea data-key="${esc(key)}" data-type="json" rows="4" spellcheck="false" style="margin-top:4px;padding:8px 10px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:12px;font-family:ui-monospace,monospace">${esc((() => { try { return JSON.stringify(JSON.parse(s.value), null, 2); } catch { return s.value; } })())}</textarea></div>`;const type = s.type === 'number' ? 'number' : (sensitive ? 'password' : 'text');return `<div class="setting-row" style="flex-direction:column;align-items:stretch">-- <label><strong>${esc(key)}</strong><br><small>${esc(s.description||'')}</small></label>++ <label><strong>${esc(aiKeyTitle(key))}</strong> <code class="pai-mkey">${esc(key)}</code><br><small>${esc(s.description||'')}</small></label><input type="${type}" data-key="${esc(key)}" data-type="${esc(s.type||'string')}" value="${esc(s.value)}"style="margin-top:4px;padding:8px 10px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px"></div>`;};@@@ -101,7 +118,7 @@ async function loadAi() {}c.innerHTML = `<div style="max-width:760px">-- <p class="muted" style="margin-bottom:12px">AI analysis for pastes, live-stream memories and AI viewers. Every call is a run on OpenVibe.AI, which holds the provider keys, models and prices (its console at ai.openvibe.network/console). Nothing runs until <strong>ai_enabled</strong> is on. Set <strong>ai_max_cost_usd_per_day</strong> to cap Live's daily spend.</p>++ <p class="muted" style="margin-bottom:12px">What OpenVibe.Live does with AI: describing pastes, remembering streams and the AI chat viewers. Every request goes through OpenVibe.AI, which holds the provider keys, models and prices (its console is at ai.openvibe.services/console). Nothing runs until <strong>${esc(aiKeyTitle('ai_enabled'))}</strong> is on, and <strong>${esc(aiKeyTitle('ai_max_cost_usd_per_day'))}</strong> caps Live's spending.</p><fieldset style="border:1px solid var(--border);border-radius:8px;padding:12px 16px;margin-bottom:16px"><legend style="padding:0 8px;font-weight:700">Estimated Cost</legend>${costHtml}@@@ -153,7 +170,7 @@ async function loadAiViewersFleet() {<span>Default engine: <b>${esc(d.engine_default)}</b></span><span>Kill switch: <b style="color:${d.kill_switch ? '#f87171' : '#4ade80'}">${d.kill_switch ? 'ENGAGED (all channels silent)' : 'off'}</b></span><span>Shared-key viewer spend today: <b>${money(d.global_spend_today_usd)}</b></span>-- <span class="muted" style="font-size:12px">The kill switch is ai_viewers_enabled in Configuration below.</span>++ <span class="muted" style="font-size:12px">The switch that silences them all is ${esc(aiKeyTitle('ai_viewers_enabled'))} in Configuration below.</span></div>${d.running.length ? `<table style="width:100%;font-size:13px;border-collapse:collapse;margin-bottom:10px"><tr><th style="text-align:left">Running now</th><th>Engine</th><th>Stream</th><th>Bots</th><th>Mode</th><th>Lines</th><th>Passes</th><th style="text-align:right">Cost</th><th></th></tr>${d.running.map(r=>`<tr><td>${esc(r.username||('#'+r.user_id))}</td><td>${esc(r.engine)}</td><td>${r.stream_id}</td><td>${r.bots}</td><td>${esc(r.paused ? 'paused' : (r.mode||''))}</td><td>${r.stats ? r.stats.lines : '—'}</td><td>${r.stats ? r.stats.ticks : '—'}</td><td style="text-align:right">${r.stats ? money(r.stats.cost) : '—'}</td>@@@ -562,21 +579,33 @@ async function testTTSVoice() {// registry, in one long stacked page (no inner tabs, nothing display:none) with a sticky in-page index. The server// proxy is /api/admin/ai/config (server/admin/ai-config-proxy.js); it holds no value, and neither does this page:// a secret is only ever written, never rendered. Network's admin helpers (api, esc, toast, timeAgo) are above.++// The words are the clear admin panel's (OWNER-SETUP-AND-ADMIN-CLARITY-DESIGN, 2026-11-07, section 3): a title in++// plain words with the machine key small and grey after it, every choice in words, Advanced settings in a counted++// fold, status as a sentence. glossaryLink() (admin/glossary.js) is optional here, so the file also loads alone.// ═══════════════════════════════════════════════════════════════const PAI_NAMES = {network: 'OpenVibe.Network', ai: 'OpenVibe.AI', billing: 'OpenVibe.Billing', services: 'OpenVibe.Services', watch: 'OpenVibe.Watch',bench: 'OpenVibe.Bench', report: 'OpenVibe.Report', host: 'OpenVibe.Host', codes: 'OpenVibe.Codes',-- actor: 'OpenVibe.Actor',++ actor: 'OpenVibe.Actor', chat: 'OpenVibe.Chat', connect: 'OpenVibe Connect', deals: 'OpenVibe.Deals', work: 'OpenVibe.Work',++ sources: 'OpenVibe.Sources', design: 'OpenVibe.Design', domains: 'OpenVibe.Domains', media: 'OpenVibe.Media',};++// The services whose settings are AI settings: their cards are on this tab. Every other service's owner inputs are++// on the Setup tab; the "Where keys are kept" list covers them all.++const PAI_AI_SERVICES = ['ai', 'billing', 'services', 'watch', 'bench', 'report', 'host', 'codes', 'actor'];const PAI_ACCESS = ['OWN_DEPLOYMENT', 'AUTHORIZED_WHOLESALE', 'CUSTOMER_BYOK', 'RESTRICTED', 'UNVERIFIED'];--const PAI_BILLING_LABEL = { metered: 'Metered', subscription: 'Subscription', payg: 'Pay as you go', free: 'Free', byok: 'Customer BYOK' };++const PAI_BILLING_LABEL = {++ metered: 'Spends Credits per request', subscription: 'A plan with a monthly fee shared by everyone',++ payg: 'A prepaid balance at the provider', free: 'Free', byok: 'The person\'s own key pays',++};const PAI_ACCESS_LABEL = {-- OWN_DEPLOYMENT: 'Own deployment', AUTHORIZED_WHOLESALE: 'Authorized wholesale', CUSTOMER_BYOK: 'Customer BYOK',-- RESTRICTED: 'Restricted', UNVERIFIED: 'Unverified',++ OWN_DEPLOYMENT: 'Runs on our own machine: no provider terms', AUTHORIZED_WHOLESALE: 'Our agreement allows reselling it',++ CUSTOMER_BYOK: 'Only with the person\'s own key', RESTRICTED: 'Do not sell: their terms forbid it',++ UNVERIFIED: 'Not checked yet: treated as Do not sell',};// An integer's x-unit, as the owner form labels it. A unit this page does not know is left off so the// input is a plain number: the page then works with a newer openvibe-shared before its pin moves.const PAI_UNIT_LABEL = { ms: 'ms', seconds: 's', bps: 'bps' };++const PAI_UNIT_WORDS = { ms: 'milliseconds', seconds: 'seconds', bps: 'bits per second' };const PAI = {providers: [], services: [], namespaces: [], warnings: [], vault: {}, serviceState: {}, loading: false, addProviderError: null,};@@@ -589,6 +618,17 @@ function paiLabel(service) {return PAI_NAMES[service] || ('OpenVibe.' + String(service || '').replace(/^./, (c) => c.toUpperCase()));}++/** A machine key in words when a schema gives no title: "min_samples" → "Min samples", "vast.key" → "Vast key". */++function paiHumanize(key) {++ const words = String(key || '').replace(/([a-z])([A-Z])/g, '$1 $2').split(/[._\-\s]+/).filter(Boolean).join(' ').toLowerCase();++ return words ? words.charAt(0).toUpperCase() + words.slice(1) : String(key || '');++}++++/** "What does this mean?" when the glossary is on the page (admin/glossary.js), nothing otherwise. */++function paiWhat(id, label) {++ return typeof glossaryLink === 'function' ? glossaryLink(id, label) : '';++}++function paiApi(path, opts = {}) {return api('/api/admin/ai/config' + path, opts).catch((err) => {if (err && err.status === 403 && err.body && err.body.code === 'config.fresh_required') {@@@ -602,7 +642,7 @@ function paiApi(path, opts = {}) {function paiShowFresh(url) {const el = document.getElementById('pai-fresh');if (!el) return;-- const link = el.querySelector('a');++ const link = el.querySelector('a.btn');if (link && url) link.href = url;el.hidden = false;}@@@ -645,16 +685,22 @@ function paiWhen(iso) {try { return timeAgo(iso); } catch { return ''; }}--function paiMarker(value) {-- return value && typeof value === 'object' && value.redacted === true ? value : null;--}--function paiFold(items, render) {const head = items.slice(0, 8).map(render).join('');if (items.length <= 8) return '<div class="pai-rows">' + head + '</div>';return '<div class="pai-rows">' + head + '</div><details class="pai-more"><summary>More (' + (items.length - 8) + ')</summary><div class="pai-rows">' + items.slice(8).map(render).join('') + '</div></details>';}++/**++ * Whether a key is a secret: the summary's classification (every schema property from openvibe-shared 4.125.0), or,++ * for an unset key of an older service, a bound or writeOnly key, or a key/token/secret/password name.++ */++function paiIsSecret(n, key, prop) {++ const cls = n.classification ? n.classification[key] : undefined;++ if (cls) return cls === 'secret';++ return !!(prop && (typeof prop['x-bind'] === 'string' || prop.writeOnly === true)) || /(^|[._-])(api_?key|key|token|secret|password)$/i.test(key);++}++async function loadPlatformAi() {const root = document.getElementById('platform-ai');if (!root) return;@@@ -677,7 +723,7 @@ async function loadPlatformAi() {PAI.serviceState = {};for (const answer of answers) {if (answer.error) {-- PAI.warnings.push({ service: answer.service, namespace: null, reason: 'unavailable', detail: paiFriendly(answer.service, answer.error) });++ // Not a warning: a service that has not mounted its settings routes yet is said in plain words below.PAI.vault[answer.service] = { sealed: null, kek: null };PAI.serviceState[answer.service] = { answered: false, sealed: null, kek: null };continue;@@@ -685,7 +731,7 @@ async function loadPlatformAi() {PAI.namespaces.push(...(answer.namespaces || []));PAI.warnings.push(...(answer.warnings || []));PAI.vault[answer.service] = answer.vault || { sealed: null, kek: null };-- PAI.serviceState[answer.service] = { answered: true, sealed: answer.vault ? answer.vault.sealed : null, kek: answer.vault ? answer.vault.kek : null };++ PAI.serviceState[answer.service] = { answered: true, published: (answer.namespaces || []).length, sealed: answer.vault ? answer.vault.sealed : null, kek: answer.vault ? answer.vault.kek : null };}paiRender(root);} catch (err) {@@@ -697,32 +743,43 @@ async function loadPlatformAi() {function paiShell(inner) {return '<div class="pai-head"><h2><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i> Platform AI</h2>'-- + '<p class="muted">Every service\'s AI settings in one place. Keys are sealed and write-only: once saved, nothing here can show one again. Money and key changes ask you to confirm it is you.</p></div>'-- + '<div id="pai-fresh" class="pai-fresh" hidden><i class="fa-solid fa-lock" aria-hidden="true"></i> <span>Confirm it\'s you to change keys and money settings.</span> <a class="btn btn-primary" href="/login?reauth=1&return=%2Fadmin%23ai">Sign in again</a></div>'++ + '<p class="muted">Everything about OpenVibe\'s AI in one place: the providers and their keys, what the Exchange offers, the money switches and other services\' AI settings. Keys are locked and write-only, and a key or money change asks you to confirm it\'s you.</p></div>'++ + '<div id="pai-fresh" class="pai-fresh" hidden><i class="fa-solid fa-lock" aria-hidden="true"></i> <span>Confirm it\'s you to change keys and money settings.</span> <a class="btn btn-primary" href="/login?reauth=1&return=%2Fadmin%23ai">Sign in again</a> ' + paiWhat('fresh-sign-in') + '</div>'+ inner;}++// A namespace made only of owner inputs for something other than AI (publishing tokens, sign-in apps, data sources,++// chat apps…) is the Setup tab's, not this one's: Codes' publishing tokens read as "AI settings" here (2026-11-07).++const PAI_NOT_AI_GROUPS = new Set(['chat-apps', 'sign-in-apps', 'data-sources', 'design-publishing', 'domains', 'legal', 'voices']);++function paiSetupOnly(n) {++ const props = n && n.schema && n.schema.properties ? Object.values(n.schema.properties) : [];++ return props.length > 0 && props.every((p) => p && p['x-owner-input'] && PAI_NOT_AI_GROUPS.has(p['x-owner-input'].group));++}++const paiAiNamespaces = () => PAI.namespaces.filter((n) => PAI_AI_SERVICES.includes(n.service) && !n.namespace.endsWith('.provider_keys') && !paiSetupOnly(n));++function paiRender(root) {-- const exchange = PAI.namespaces.filter((n) => n.service === 'ai' && !n.namespace.endsWith('.provider_keys'));-- const money = PAI.namespaces.filter((n) => n.service === 'billing');-- const rest = PAI.namespaces.filter((n) => n.service !== 'ai' && n.service !== 'billing' && !n.namespace.endsWith('.provider_keys'));++ const all = paiAiNamespaces();++ const exchange = all.filter((n) => n.service === 'ai');++ const money = all.filter((n) => n.service === 'billing');++ const rest = all.filter((n) => n.service !== 'ai' && n.service !== 'billing');const count = (n) => n ? ' <span class="pai-count">' + n + '</span>' : '';root.innerHTML = paiShell('<nav class="pai-index" aria-label="Platform AI sections">'-- + '<a href="#pai-providers">Providers and accounts' + count(PAI.providers.length) + '</a>'-- + '<a href="#pai-exchange">AI Exchange' + count(exchange.length) + '</a>'++ + '<a href="#pai-providers">AI providers' + count(PAI.providers.length) + '</a>'++ + '<a href="#pai-exchange">What the Exchange offers' + count(exchange.length) + '</a>'+ '<a href="#pai-money">Money' + count(money.length) + '</a>'-- + '<a href="#pai-services">Services' + count(rest.length) + '</a>'-- + '<a href="#pai-vault">Vault' + count(PAI.services.length) + '</a>'++ + '<a href="#pai-services">Other services\' AI settings' + count(rest.length) + '</a>'++ + '<a href="#pai-vault">Where keys are kept' + count(PAI.services.length) + '</a>'+ '</nav>'+ '<div id="pai-warnings"></div>'-- + '<section id="pai-providers" class="pai-section"><div class="pai-section-head"><h3>Providers and accounts</h3><p class="muted">The providers OpenVibe.AI may call, their billing profile and their key. A subscription account runs on a shared pool; a metered one spends Credits.</p></div>'++ + '<section id="pai-providers" class="pai-section"><div class="pai-section-head"><h3>AI providers</h3><p class="muted">The companies whose models OpenVibe can use, and the key for each. Add one to make its models available; a provider with no working key is skipped.</p>'++ + '<p class="pai-words muted">Words used here: ' + [paiWhat('resale', 'reselling'), paiWhat('byok', 'the person\'s own key'), paiWhat('health', 'paused'), paiWhat('credits', 'Credits')].filter(Boolean).join(' · ') + '</p></div>'+ '<div class="pai-toolbar"><button type="button" class="btn btn-primary" id="pai-add-provider"><i class="fa-solid fa-plus" aria-hidden="true"></i> Add provider</button></div>'+ '<div id="pai-providers-list"></div></section>'-- + '<section id="pai-exchange" class="pai-section"><h3>AI Exchange</h3><p class="muted">What OpenVibe.AI routes, which models it offers and what it may spend.</p><div id="pai-cards-exchange" data-pai-cards></div></section>'-- + '<section id="pai-money" class="pai-section"><h3>Money</h3><p class="muted">Credits, top-ups and the sandbox. Every money switch says exactly what it turns on.</p><div id="pai-cards-money" data-pai-cards></div></section>'-- + '<section id="pai-services" class="pai-section"><h3>Services</h3><p class="muted">The settings of every other service, drawn from each namespace\'s schema.</p><div id="pai-cards-services" data-pai-cards></div></section>'-- + '<section id="pai-vault" class="pai-section"><h3>Vault</h3><p class="muted">Secrets are sealed with the service\'s own key-encryption key (KEK); without one the store keeps its pre-D11 plaintext behaviour and says so.</p><div id="pai-vault-list"></div></section>'++ + '<section id="pai-exchange" class="pai-section"><div class="pai-section-head"><h3>What the Exchange offers</h3><p class="muted">Which models people can pick on openvibe.exchange, the free tier, and the spending limits that protect your balance.</p></div><div id="pai-cards-exchange" data-pai-cards></div></section>'++ + '<section id="pai-money" class="pai-section"><div class="pai-section-head"><h3>Money</h3><p class="muted">Credits are what people spend on AI; these switches decide whether people can buy them, top up and pay sellers. Each switch says what it turns on before you change it. ' + paiWhat('credits') + '</p></div><div id="pai-cards-money" data-pai-cards></div></section>'++ + '<section id="pai-services" class="pai-section"><div class="pai-section-head"><h3>Other services\' AI settings</h3><p class="muted">AI settings that live in other parts of OpenVibe (Codes, Actor, Bench…), one card per service. Their other keys (publishing, sign-in apps, data sources) are on the <a href="/admin/setup">Setup</a> tab.</p></div><div id="pai-cards-services" data-pai-cards></div></section>'++ + '<section id="pai-vault" class="pai-section"><div class="pai-section-head"><h3>Where keys are kept</h3><p class="muted">Every key you enter is locked with that service\'s own lock key and can never be read back, not even by you. A service without its lock key stores keys unlocked and says so here: fix that first. ' + paiWhat('lock-key') + '</p></div><div id="pai-vault-list"></div></section>');paiRenderWarnings();paiRenderProviders();@@@ -751,11 +808,12 @@ function paiRenderWarnings() {const box = document.getElementById('pai-warnings');if (!box) return;if (!PAI.warnings.length) { box.innerHTML = ''; return; }-- box.innerHTML = '<div class="pai-warning"><strong>Some answers were dropped.</strong> The service answered with something that is not a settings summary, so it is not shown.'++ box.innerHTML = '<div class="pai-warning"><strong>Some settings could not be shown.</strong> These services answered with something that is not a settings summary, so their cards are left out until they answer correctly.'+ PAI.warnings.map((w) => '<div class="pai-warning-row">' + esc(paiLabel(w.service)) + (w.namespace ? ' · <code>' + esc(w.namespace) + '</code>' : '') + ' — ' + esc(w.reason === 'invalid-summary' ? 'not a settings summary' : (w.reason || 'not available')) + '</div>').join('')+ '</div>';}++/** Key: "Entered here, locked" / "From the server's settings file (NAME)" / "Not set". */function paiKeyState(p) {const ref = String(p.secret_ref || '');const vault = ref.startsWith('vault:');@@@ -763,27 +821,48 @@ function paiKeyState(p) {const hint = p.key_hint || p.hint || p.secret_hint || '';const bound = p.bound_to || paiHost(p.base_url);const required = p.auth_mode !== 'none' && p.credentials !== 'not_required';-- if (!required) return { tone: 'muted', text: 'Not required', required: false };-- if (vault && p.credentials !== 'missing') return { tone: 'ok', text: 'Sealed' + (hint ? ' ••••' + esc(hint) : '') + ' · bound to ' + esc(paiHost(bound)), required: true };-- if (env) return { tone: 'muted', text: 'From env ' + esc(ref.slice(4)), env: ref.slice(4), required: true };-- if (p.credentials === 'missing' || !ref) return { tone: 'bad', text: 'No key', required: true };-- return { tone: 'ok', text: 'Sealed · bound to ' + esc(paiHost(bound)), required: true };++ if (!required) return { tone: 'muted', text: 'Key: not needed', required: false };++ if (vault && p.credentials !== 'missing') return { tone: 'ok', text: 'Key: Entered here, locked' + (hint ? ' · ••••' + esc(hint) : '') + ' · only sent to ' + esc(paiHost(bound)), required: true };++ if (env) return { tone: 'muted', text: 'Key: From the server\'s settings file (' + esc(ref.slice(4)) + ')', env: ref.slice(4), required: true };++ if (p.credentials === 'missing' || !ref) return { tone: 'bad', text: 'Key: Not set', required: true };++ return { tone: 'ok', text: 'Key: Entered here, locked · only sent to ' + esc(paiHost(bound)), required: true };++}++++/** Health: "Working" / "Paused after N errors; tries again at HH:MM" / "Testing again" (AI's circuit, in words). */++function paiHealth(p) {++ const h = p && p.health;++ if (!h || !h.state) return null;++ if (h.state === 'closed') return { tone: 'ok', text: 'Health: Working' };++ if (h.state === 'half_open') return { tone: 'muted', text: 'Health: Testing again' };++ if (h.state === 'open') {++ const n = Number(h.consecutive_failures) || 0;++ const errors = n === 1 ? '1 error' : n + ' errors';++ let retry = h.retry_at ? Date.parse(h.retry_at) : NaN;++ const opened = typeof h.opened_at === 'number' ? h.opened_at : Date.parse(h.opened_at || '');++ // AI waits a minute after errors, and ten minutes when the provider's balance ran out.++ if (!Number.isFinite(retry) && Number.isFinite(opened)) retry = opened + (h.exhausted_at ? 10 * 60000 : 60000);++ const at = Number.isFinite(retry) ? new Date(retry).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : null;++ return { tone: 'bad', text: 'Health: Paused after ' + errors + (h.exhausted_at ? ' (its balance ran out)' : '') + (at ? '; tries again at ' + at : '; it tries again shortly') };++ }++ return null;}function paiProviderRow(p) {const access = PAI_ACCESS.includes(String(p.provider_access || '').toUpperCase()) ? String(p.provider_access).toUpperCase() : 'UNVERIFIED';-- const billing = p.billing_profile === 'subscription' ? 'Subscription · ' + esc(p.pool_key || p.key) : esc(PAI_BILLING_LABEL[p.billing_profile] || PAI_BILLING_LABEL.metered);++ const billing = p.billing_profile === 'subscription' ? esc(PAI_BILLING_LABEL.subscription) + ' (' + esc(p.pool_key || p.key) + ')' : esc(PAI_BILLING_LABEL[p.billing_profile] || PAI_BILLING_LABEL.metered);const key = paiKeyState(p);++ const health = paiHealth(p);const sealed = String(p.secret_ref || '').startsWith('vault:') && p.credentials !== 'missing';const actions = '<button type="button" class="btn btn-sm pai-quiet" data-act="edit">Edit</button> '-- + (key.required && key.env ? '<button type="button" class="btn btn-sm pai-quiet" data-act="key">Move into the vault</button> ' : '')-- + (key.required ? '<button type="button" class="btn btn-sm pai-quiet" data-act="key">' + (sealed ? 'Replace key' : 'Add key') + '</button> ' : '')++ + (key.required && key.env ? '<button type="button" class="btn btn-sm pai-quiet" data-act="key" title="Enter the key here so it is locked by OpenVibe.AI instead of read from the server\'s settings file">Enter it here instead</button> ' : '')++ + (key.required && !key.env ? '<button type="button" class="btn btn-sm pai-quiet" data-act="key">' + (sealed ? 'Replace key' : 'Add key') + '</button> ' : '')+ (key.required && sealed ? '<button type="button" class="btn btn-sm pai-quiet pai-quiet-danger" data-act="remove-key">Remove key</button>' : '');return '<div class="pai-provider" data-pai-provider="' + esc(p.key) + '">'-- + '<div class="pai-provider-main"><div class="pai-provider-name">' + esc(p.display_name || p.key) + ' <span class="pai-kind">' + esc(p.kind || '—') + '</span></div>'++ + '<div class="pai-provider-main"><div class="pai-provider-name">' + esc(p.display_name || p.key) + ' <code class="pai-mkey">' + esc(p.key) + '</code></div>'+ '<div class="pai-provider-meta"><code>' + esc(paiHost(p.base_url)) + '</code> <span class="pai-access is-' + access.toLowerCase() + '">' + esc(PAI_ACCESS_LABEL[access]) + '</span> <span class="pai-billing">' + billing + '</span></div>'-- + '<div class="pai-keystate is-' + key.tone + '">' + key.text + '</div></div>'-- + '<div class="pai-provider-actions"><label class="pai-switch"><input type="checkbox" role="switch" data-pai-enabled data-ov-native ' + (p.status === 'active' ? 'checked' : '') + '><span class="pai-track" aria-hidden="true"><span class="pai-knob"></span></span><span class="pai-switch-text">' + (p.status === 'active' ? 'Enabled' : 'Disabled') + '</span></label>' + actions + '</div></div>';++ + '<div class="pai-keystate is-' + key.tone + '">' + key.text + '</div>'++ + (health ? '<div class="pai-keystate is-' + health.tone + '">' + esc(health.text) + '</div>' : '') + '</div>'++ + '<div class="pai-provider-actions"><label class="pai-switch"><input type="checkbox" role="switch" data-pai-enabled data-ov-native ' + (p.status === 'active' ? 'checked' : '') + '><span class="pai-track" aria-hidden="true"><span class="pai-knob"></span></span><span class="pai-switch-text">' + (p.status === 'active' ? 'In use' : 'Not used') + '</span></label>' + actions + '</div></div>';}function paiRenderProviders() {@@@ -794,13 +873,13 @@ function paiRenderProviders() {return;}if (!PAI.providers.length) {-- box.innerHTML = '<p class="muted">No provider is configured yet. Add one to let OpenVibe.AI answer.</p>';++ box.innerHTML = '<p class="muted">No providers yet. Add DeepSeek to start: press Add provider above.</p>';return;}const subscription = PAI.providers.filter((p) => p.billing_profile === 'subscription');const other = PAI.providers.filter((p) => p.billing_profile !== 'subscription');box.innerHTML = paiFold(other, paiProviderRow)-- + (subscription.length ? '<h4 class="pai-subhead">Subscription accounts (Claude, Codex, …)</h4>' + paiFold(subscription, paiProviderRow) : '');++ + (subscription.length ? '<h4 class="pai-subhead">On a plan with a monthly fee (Claude, Codex, …)</h4>' + paiFold(subscription, paiProviderRow) : '');}function paiProviderClick(ev) {@@@ -826,12 +905,12 @@ async function paiProviderSwitch(ev) {await paiApi('/providers/' + encodeURIComponent(provider.key), { method: 'PATCH', body: { status: input.checked ? 'active' : 'disabled' } });provider.status = input.checked ? 'active' : 'disabled';const label = input.closest('.pai-switch').querySelector('.pai-switch-text');-- if (label) label.textContent = input.checked ? 'Enabled' : 'Disabled';-- toast(provider.display_name + (input.checked ? ' enabled' : ' disabled'), 'success');++ if (label) label.textContent = input.checked ? 'In use' : 'Not used';++ toast((provider.display_name || provider.key) + (input.checked ? ' is in use' : ' is no longer used'), 'success');} catch (err) {input.checked = !input.checked;const label = input.closest('.pai-switch').querySelector('.pai-switch-text');-- if (label) label.textContent = input.checked ? 'Enabled' : 'Disabled';++ if (label) label.textContent = input.checked ? 'In use' : 'Not used';if (!err.freshRequired) toast(paiFriendly('ai', err), 'error');} finally {input.disabled = false;@@@ -848,35 +927,35 @@ function paiProviderDialog(provider) {dlg.className = 'pai-dialog';const options = (items, current) => items.map((item) => '<option value="' + esc(item.value) + '"' + (item.value === current ? ' selected' : '') + ' data-desc="' + esc(item.desc || '') + '" data-state="' + (item.state || 'off') + '">' + esc(item.label) + '</option>').join('');const kindOptions = [-- { value: 'openai', label: 'OpenAI-compatible', desc: 'Chat/completions with a bearer key', state: 'ok' },-- { value: 'anthropic', label: 'Anthropic Messages', desc: 'Claude\'s own messages API', state: 'ok' },-- { value: 'http', label: 'Plain HTTP seam', desc: 'A custom endpoint that speaks JSON', state: 'off' },-- { value: 'stub', label: 'Deterministic stub', desc: 'Synthetic answers for tests', state: 'off' },-- { value: 'whisper', label: 'whisper.cpp', desc: 'Local speech-to-text', state: 'off' },++ { value: 'openai', label: 'OpenAI-compatible', desc: 'Most providers: the chat API OpenAI made, with a key', state: 'ok' },++ { value: 'anthropic', label: 'Anthropic (Claude)', desc: 'Claude\'s own messages API', state: 'ok' },++ { value: 'http', label: 'Our own HTTP endpoint', desc: 'A custom address that speaks JSON', state: 'off' },++ { value: 'stub', label: 'Practice answers', desc: 'For testing only: canned answers, no provider', state: 'off' },++ { value: 'whisper', label: 'whisper.cpp (speech to text)', desc: 'Speech to text on our own machine', state: 'off' },];const billingOptions = [-- { value: 'metered', label: 'Metered', desc: 'Every request spends Credits', state: 'ok' },-- { value: 'subscription', label: 'Subscription', desc: 'Runs on a shared plan pool', state: 'ok' },-- { value: 'payg', label: 'Pay as you go', desc: 'A prepaid provider balance', state: 'off' },-- { value: 'free', label: 'Free', desc: 'No provider charge', state: 'off' },-- { value: 'byok', label: 'Customer BYOK', desc: 'A customer\'s own key pays the provider', state: 'new' },++ { value: 'metered', label: PAI_BILLING_LABEL.metered, desc: 'Each request takes Credits from the person who asked', state: 'ok' },++ { value: 'subscription', label: PAI_BILLING_LABEL.subscription, desc: 'Name the plan below; requests share its allowance', state: 'ok' },++ { value: 'payg', label: PAI_BILLING_LABEL.payg, desc: 'You top it up on the provider\'s own site', state: 'off' },++ { value: 'free', label: PAI_BILLING_LABEL.free, desc: 'The provider charges nothing', state: 'off' },++ { value: 'byok', label: PAI_BILLING_LABEL.byok, desc: 'Each person enters their own key and pays the provider', state: 'new' },];dlg.innerHTML = '<form method="dialog" class="pai-form">'+ '<h3>' + (editing ? 'Edit provider' : 'Add provider') + '</h3>'-- + '<div class="ov-field"><label for="pai-p-key">Key</label><input id="pai-p-key" name="key" required pattern="[a-z][a-z0-9_-]*([.][a-z0-9_-]+)*" ' + (editing ? 'readonly' : '') + ' value="' + esc(p.key || '') + '"><p class="ov-field-hint">Lowercase, dotted or dashed: deepseek, claude, codex. It cannot be renamed.</p></div>'-- + '<div class="ov-field"><label for="pai-p-name">Display name</label><input id="pai-p-name" name="display_name" value="' + esc(p.display_name || '') + '"></div>'-- + '<div class="ov-field"><label for="pai-p-kind">Kind</label><select id="pai-p-kind" name="kind">'++ + '<div class="ov-field"><label for="pai-p-key">Short name</label><input id="pai-p-key" name="key" required pattern="[a-z][a-z0-9_-]*([.][a-z0-9_-]+)*" ' + (editing ? 'readonly' : '') + ' value="' + esc(p.key || '') + '"><p class="ov-field-hint">Lowercase, with dots or dashes: deepseek, claude, codex. It cannot be renamed later.</p></div>'++ + '<div class="ov-field"><label for="pai-p-name">Name people see</label><input id="pai-p-name" name="display_name" value="' + esc(p.display_name || '') + '"></div>'++ + '<div class="ov-field"><label for="pai-p-kind">How OpenVibe talks to it</label><select id="pai-p-kind" name="kind">'+ options(kindOptions, kind)+ '</select></div>'-- + '<div class="ov-field"><label for="pai-p-url">Base URL</label><input id="pai-p-url" name="base_url" type="url" placeholder="https://api.example.com" value="' + esc(p.base_url || '') + '"><p class="ov-field-hint">A sealed key is bound to this address: changing it means entering the key again.</p></div>'-- + '<div class="ov-field"><label for="pai-p-access">Provider access</label><select id="pai-p-access" name="provider_access" data-ov-title="Provider access">'-- + PAI_ACCESS.map((a) => '<option value="' + a + '"' + (a === access ? ' selected' : '') + ' data-badge="' + esc(PAI_ACCESS_LABEL[a]) + '" data-desc="' + esc(paiAccessDesc(a)) + '" data-state="' + (a === 'OWN_DEPLOYMENT' || a === 'AUTHORIZED_WHOLESALE' ? 'ok' : a === 'RESTRICTED' ? 'warn' : a === 'UNVERIFIED' ? 'off' : 'new') + '">' + esc(PAI_ACCESS_LABEL[a]) + '</option>').join('')-- + '</select></div>'-- + '<div class="pai-form-row"><div class="ov-field"><label for="pai-p-billing">Billing profile</label><select id="pai-p-billing" name="billing_profile">'++ + '<div class="ov-field"><label for="pai-p-url">Address (base URL)</label><input id="pai-p-url" name="base_url" type="url" placeholder="https://api.example.com" value="' + esc(p.base_url || '') + '"><p class="ov-field-hint">The key is only ever sent to this address. Changing it means entering the key again.</p></div>'++ + '<div class="ov-field"><label for="pai-p-access">May OpenVibe sell its use?</label><select id="pai-p-access" name="provider_access" data-ov-title="May OpenVibe sell its use?">'++ + PAI_ACCESS.map((a) => '<option value="' + a + '"' + (a === access ? ' selected' : '') + ' data-desc="' + esc(paiAccessDesc(a)) + '" data-state="' + (a === 'OWN_DEPLOYMENT' || a === 'AUTHORIZED_WHOLESALE' ? 'ok' : a === 'RESTRICTED' ? 'warn' : a === 'UNVERIFIED' ? 'off' : 'new') + '">' + esc(PAI_ACCESS_LABEL[a]) + '</option>').join('')++ + '</select><p class="ov-field-hint">Check the provider\'s terms. ' + paiWhat('resale') + '</p></div>'++ + '<div class="pai-form-row"><div class="ov-field"><label for="pai-p-billing">Who pays</label><select id="pai-p-billing" name="billing_profile">'+ options(billingOptions, billing)-- + '</select></div><div class="ov-field"><label for="pai-p-pool">Pool</label><input id="pai-p-pool" name="pool_key" placeholder="claude-max" value="' + esc(p.pool_key || '') + '"><p class="ov-field-hint">Subscription pools only; a name, never a secret.</p></div></div>'-- + '<div class="pai-form-row"><div class="ov-field"><label for="pai-p-priority">Priority</label><input id="pai-p-priority" name="priority" type="number" step="1" value="' + esc(p.priority == null ? 100 : p.priority) + '"></div>'-- + '<div class="ov-field"><label for="pai-p-model">Default model</label><input id="pai-p-model" name="default_model" value="' + esc(p.default_model || '') + '"></div></div>'++ + '</select></div><div class="ov-field"><label for="pai-p-pool">Shared plan name</label><input id="pai-p-pool" name="pool_key" placeholder="claude-max" value="' + esc(p.pool_key || '') + '"><p class="ov-field-hint">Only for a plan with a monthly fee; a name, never a key.</p></div></div>'++ + '<div class="pai-form-row"><div class="ov-field"><label for="pai-p-priority">Order</label><input id="pai-p-priority" name="priority" type="number" step="1" value="' + esc(p.priority == null ? 100 : p.priority) + '"><p class="ov-field-hint">A lower number is tried first.</p></div>'++ + '<div class="ov-field"><label for="pai-p-model">Model used when none is named</label><input id="pai-p-model" name="default_model" value="' + esc(p.default_model || '') + '"></div></div>'+ '<div id="pai-p-key-field"></div>'+ '<div class="pai-dialog-actions"><button type="button" class="btn" data-act="cancel">Cancel</button><button type="submit" class="btn btn-primary" data-act="save">' + (editing ? 'Save' : 'Add provider') + '</button></div>'+ '</form>';@@@ -885,16 +964,16 @@ function paiProviderDialog(provider) {keyField.className = 'ov-field';const keyLabel = document.createElement('label');keyLabel.htmlFor = 'pai-p-value';-- keyLabel.textContent = editing ? 'Replace key (write-only)' : 'Key (write-only)';++ keyLabel.textContent = editing ? 'Replace the API key (write-only)' : 'API key (write-only)';const keyInput = document.createElement('input');keyInput.id = 'pai-p-value';keyInput.type = 'password';keyInput.autocomplete = 'off';keyInput.spellcheck = false;-- keyInput.placeholder = editing ? 'Leave empty to keep the key' : 'Paste the provider key';++ keyInput.placeholder = editing ? 'Leave empty to keep the key' : 'Paste the provider\'s API key';const keyHint = document.createElement('p');keyHint.className = 'ov-field-hint';-- keyHint.textContent = 'Sent once and sealed by OpenVibe.AI; it is never shown again.';++ keyHint.textContent = 'Sent once and locked by OpenVibe.AI; it is never shown again.';keyField.append(keyLabel, keyInput, keyHint);dlg.querySelector('#pai-p-key-field').append(keyField);document.body.appendChild(dlg);@@@ -917,16 +996,15 @@ function paiProviderDialog(provider) {metadata: { ...(p.metadata || {}), provider_access: data.get('provider_access') },};if (editing && p.base_url && body.base_url !== p.base_url && String(p.secret_ref || '').startsWith('vault:')) {-- const ok = await paiConfirm('Change the base URL?', ['Its sealed key is bound to the old address and will stop working until you enter it again.']);++ const ok = await paiConfirm('Change the address?', ['Its locked key is only sent to the old address, so it stops working until you enter it again.', 'To undo it, put the old address back; the key works again.']);if (!ok) return;}const save = form.querySelector('[data-act="save"]');save.disabled = true;save.textContent = 'Saving…';try {-- let created;-- if (editing) created = await paiApi('/providers/' + encodeURIComponent(p.key), { method: 'PATCH', body });-- else created = await paiApi('/providers', { method: 'POST', body });++ if (editing) await paiApi('/providers/' + encodeURIComponent(p.key), { method: 'PATCH', body });++ else await paiApi('/providers', { method: 'POST', body });const key = body.key || p.key;if (keyInput.value) {await paiApi('/providers/' + encodeURIComponent(key) + '/key', { method: 'PUT', body: { value: keyInput.value, reason: editing ? 'Replaced from the admin panel' : 'Added from the admin panel' } });@@@ -949,11 +1027,11 @@ function paiProviderDialog(provider) {function paiAccessDesc(access) {return {-- OWN_DEPLOYMENT: 'A model on OpenVibe\'s own machine: no provider terms limit it.',-- AUTHORIZED_WHOLESALE: 'A wholesale agreement permits resale; check the agreement covers this model.',-- CUSTOMER_BYOK: 'The customer\'s own key: they pay the provider and the terms are theirs.',-- RESTRICTED: 'The provider\'s terms do not permit resale: do not sell this capacity.',-- UNVERIFIED: 'Not reviewed yet: treat it as restricted until someone checks the terms.',++ OWN_DEPLOYMENT: 'A model on OpenVibe\'s own machine, so no provider\'s terms limit who may use it.',++ AUTHORIZED_WHOLESALE: 'An agreement with the provider lets OpenVibe sell its use; check that it covers this model.',++ CUSTOMER_BYOK: 'Only people who enter their own key for this provider can use it; they pay the provider.',++ RESTRICTED: 'The provider\'s terms forbid selling its use, so OpenVibe never does.',++ UNVERIFIED: 'Nobody has read the terms yet, so OpenVibe treats it as Do not sell until someone does.',}[access] || '';}@@@ -961,7 +1039,7 @@ async function paiKeyDialog(provider) {const dlg = document.createElement('dialog');dlg.className = 'pai-dialog';const state = paiKeyState(provider);-- dlg.innerHTML = '<form method="dialog" class="pai-form"><h3>Key for ' + esc(provider.display_name || provider.key) + '</h3>'++ dlg.innerHTML = '<form method="dialog" class="pai-form"><h3>API key for ' + esc(provider.display_name || provider.key) + '</h3>'+ '<p class="muted">' + state.text + '</p>'+ '<div id="pai-key-field"></div>'+ '<div class="pai-dialog-actions"><button type="button" class="btn" data-act="cancel">Cancel</button><button type="submit" class="btn btn-primary" data-act="save">Save key</button></div></form>';@@@ -969,7 +1047,7 @@ async function paiKeyDialog(provider) {field.className = 'ov-field';const label = document.createElement('label');label.htmlFor = 'pai-key-value';-- label.textContent = 'Provider key (write-only)';++ label.textContent = 'API key (write-only)';const input = document.createElement('input');input.id = 'pai-key-value';input.type = 'password';@@@ -978,7 +1056,7 @@ async function paiKeyDialog(provider) {input.required = true;const hint = document.createElement('p');hint.className = 'ov-field-hint';-- hint.textContent = 'Sent once, sealed by OpenVibe.AI; the box is cleared and removed right after saving.';++ hint.textContent = 'Sent once and locked by OpenVibe.AI; the box is cleared and removed right after saving.';field.append(label, input, hint);dlg.querySelector('#pai-key-field').append(field);document.body.appendChild(dlg);@@@ -996,7 +1074,7 @@ async function paiKeyDialog(provider) {save.textContent = 'Saving…';try {await paiApi('/providers/' + encodeURIComponent(provider.key) + '/key', { method: 'PUT', body: { value, reason: 'Replaced from the admin panel' } });-- toast('Key sealed', 'success');++ toast('Key locked', 'success');dlg.close();await loadPlatformAi();} catch (err) {@@@ -1016,7 +1094,7 @@ async function paiKeyDialog(provider) {}async function paiRemoveKey(provider) {-- const ok = await paiConfirm('Remove the key?', [paiLabel('ai') + ' will stop calling ' + (provider.display_name || provider.key) + ' until a key is entered again.']);++ const ok = await paiConfirm('Remove the key?', [paiLabel('ai') + ' stops calling ' + (provider.display_name || provider.key) + ' until a key is entered again.', 'To undo it, enter the key again with Add key.']);if (!ok) return;try {await paiApi('/providers/' + encodeURIComponent(provider.key) + '/key', { method: 'DELETE', body: { reason: 'Removed from the admin panel' } });@@@ -1028,25 +1106,43 @@ async function paiRemoveKey(provider) {}function paiRenderCards() {-- paiFillCards('exchange', PAI.namespaces.filter((n) => n.service === 'ai' && !n.namespace.endsWith('.provider_keys')), 'OpenVibe.AI has not published the ai.exchange namespace yet.', 'AI Exchange');-- paiFillCards('money', PAI.namespaces.filter((n) => n.service === 'billing'), 'OpenVibe.Billing has not published a settings namespace yet.', 'Money');-- paiFillCards('services', PAI.namespaces.filter((n) => n.service !== 'ai' && n.service !== 'billing' && !n.namespace.endsWith('.provider_keys')), 'No other service has published settings yet.', 'Services');++ const all = paiAiNamespaces();++ paiFillCards('exchange', all.filter((n) => n.service === 'ai'), 'OpenVibe.AI has not published what the Exchange offers yet.', 'What the Exchange offers');++ paiFillCards('money', all.filter((n) => n.service === 'billing'), 'OpenVibe.Billing has not published its money settings yet.', 'Money');++ paiFillCards('services', all.filter((n) => n.service !== 'ai' && n.service !== 'billing'), 'No other service has published AI settings yet.', 'Other services\' AI settings');}function paiFillCards(group, list, empty, sectionTitle) {const box = document.getElementById('pai-cards-' + group);if (!box) return;-- if (!list.length) { box.innerHTML = '<p class="muted">' + empty + '</p>'; return; }++ if (!list.length) { box.innerHTML = '<p class="muted">' + esc(empty) + '</p>'; return; }const groups = new Map();for (const n of list) {-- const name = (n.schema && n.schema['x-group']) || 'General';++ const name = (n.schema && n.schema['x-group']) || paiLabel(n.service);if (!groups.has(name)) groups.set(name, []);groups.get(name).push(n);}-- // One group (or no x-group) needs no heading: the section's own title already says it.++ // One group needs no heading: the section's own title already says it.const showGroups = groups.size > 1;const single = list.length === 1;-- box.innerHTML = [...groups.entries()].map(([name, items]) => (showGroups ? '<h4 class="pai-group">' + esc(name) + '</h4>' : '') + items.map((n) => paiCard(n, { hideTitle: single && (n.schema && (n.schema.title || n.namespace)) === sectionTitle })).join('')).join('');++ box.innerHTML = [...groups.entries()].map(([name, items]) => (showGroups ? '<h4 class="pai-group">' + esc(name) + '</h4>' : '') + items.map((n) => paiCard(n, { hideTitle: single && (n.schema && n.schema.title) === sectionTitle })).join('')).join('');++}++++/** A field folds under Advanced settings when its schema says x-advanced, or when it is raw JSON (an object with no++ * described properties): raw JSON is never in the main form. */++function paiAdvanced(prop) {++ const types = Array.isArray(prop.type) ? prop.type : [prop.type];++ const type = types.find((t) => t !== 'null') || 'string';++ const raw = type === 'object' && !(prop.properties && typeof prop.properties === 'object' && Object.keys(prop.properties).length);++ return prop['x-advanced'] === true || raw;++}++++/** Who saved a version, in words: you, a service by name, or another account (never a raw usr_ id). */++function paiWho(ref) {++ if (!ref || !ref.id) return '';++ if (currentUser && ref.id === currentUser.subject_id) return '@' + currentUser.username;++ if (ref.type === 'service' || ref.type === 'system') return paiLabel(ref.id);++ return 'another account';}function paiCard(n, opts = {}) {@@@ -1054,65 +1150,114 @@ function paiCard(n, opts = {}) {const props = schema.properties || {};const pins = Array.isArray(n.pins) ? n.pins : (n.pins && typeof n.pins === 'object' ? Object.keys(n.pins) : []);const pinEnv = n.pin_env || {};-- const fields = Object.entries(props).map(([key, prop]) => paiField(n, key, prop, pins.includes(key), pinEnv[key])).join('');-- const actor = n.active && n.active.activated_by && n.active.activated_by.id ? n.active.activated_by.id : (n.active && n.active.created_by && n.active.created_by.id ? n.active.created_by.id : null);-- const who = actor ? (currentUser && actor === currentUser.subject_id ? '@' + currentUser.username : actor) : '';++ const entries = Object.entries(props).filter(([, prop]) => prop && typeof prop === 'object');++ const main = entries.filter(([, prop]) => !paiAdvanced(prop));++ const advanced = entries.filter(([, prop]) => paiAdvanced(prop));++ const fieldOf = ([key, prop]) => paiField(n, key, prop, pins.includes(key), pinEnv[key]);++ const fields = main.map(fieldOf).join('');++ const fold = advanced.length++ ? '<details class="pai-advanced"><summary>Advanced settings (' + advanced.length + ')</summary><div class="pai-fields">' + advanced.map(fieldOf).join('') + '</div></details>'++ : '';++ const who = paiWho(n.active && (n.active.activated_by || n.active.created_by));const at = n.active && (n.active.activated_at || n.active.created_at);-- const revised = n.revision == null ? 'Not published' : 'Revision ' + esc(n.revision);-- const title = opts.hideTitle ? '' : '<h5>' + esc(schema.title || n.namespace) + '</h5>';++ const revised = n.revision == null ? 'Never changed: these are the defaults' : 'Saved version ' + esc(n.revision);++ const title = opts.hideTitle ? '' : '<h5>' + esc(schema.title || paiHumanize(n.namespace.split('.').slice(1).join(' '))) + ' <code class="pai-mkey">' + esc(n.namespace) + '</code></h5>';const description = schema.description ? '<p class="muted">' + esc(schema.description) + '</p>' : '';const head = title || description ? '<header class="pai-card-head">' + title + description + '</header>' : '';return '<article class="pai-card" data-pai-card data-service="' + esc(n.service) + '" data-namespace="' + esc(n.namespace) + '">'+ head-- + '<div class="pai-fields">' + fields + '</div>'-- + '<footer class="pai-card-foot"><span class="pai-revision">' + revised + (who ? ' · ' + esc(who) : '') + (at ? ' · ' + esc(paiWhen(at)) : '') + '</span>'++ + (fields ? '<div class="pai-fields">' + fields + '</div>' : '')++ + fold++ + '<footer class="pai-card-foot"><span class="pai-revision">' + revised + (who ? ' · by ' + esc(who) : '') + (at ? ' · ' + esc(paiWhen(at)) : '') + ' ' + paiWhat('version', 'What is a version?') + '</span>'+ '<span class="pai-card-actions"><button type="button" class="btn btn-sm pai-quiet" data-act="history">History</button><button type="button" class="btn btn-sm btn-primary" data-act="save">Save</button></span></footer>'+ '</article>';}--function paiField(n, key, prop, pinned, pinName) {-- const value = n.values ? n.values[key] : undefined;-- const cls = (n.classification && n.classification[key]) || 'internal';-- const danger = !!prop['x-danger'];-- const title = prop.title || key;-- const description = prop.description || '';++/** One choice's options: the schema's x-enum-labels in words with their line, or the value in words when it has none. */++function paiOptions(prop, value) {++ const labels = prop['x-enum-labels'] && typeof prop['x-enum-labels'] === 'object' ? prop['x-enum-labels'] : {};++ return prop.enum.map((v) => {++ const k = typeof v === 'string' ? v : JSON.stringify(v);++ const l = labels[k] && typeof labels[k] === 'object' ? labels[k] : null;++ const text = l && l.label ? l.label : (v === null ? 'None' : paiHumanize(String(v)));++ return '<option value="' + esc(k) + '"' + (String(value) === k ? ' selected' : '') + (l && l.description ? ' data-desc="' + esc(l.description) + '"' : '') + '>' + esc(text) + '</option>';++ }).join('');++}++++/** The input for one value. `path` is the key, or "parent.child" for a field of an object drawn as a form. */++function paiControl(path, prop, value, { pinned = false, title = '', nested = false } = {}) {const types = Array.isArray(prop.type) ? prop.type : [prop.type];const type = types.find((t) => t !== 'null') || 'string';const nullable = types.includes('null');-- const chips = (pinned ? '<span class="pai-chip-lock">🔒 Pinned</span>' : '') + (danger ? '<span class="pai-chip-danger">' + esc(/money|credit|spend|pay|price|usd/i.test(title + ' ' + description + ' ' + key) ? 'Money' : 'Danger') + '</span>' : '') + (cls === 'secret' ? '<span class="pai-chip-secret">Secret</span>' : '');const dis = pinned ? ' disabled' : '';-- let control;-- if (cls === 'secret') {-- const marker = paiMarker(value);-- control = '<div class="pai-secret"><span class="pai-marker">' + (marker ? 'Sealed ••••' + esc(marker.hint || '') : 'Not set') + '</span>'-- + (pinned ? '' : ' <button type="button" class="btn btn-sm pai-quiet" data-act="secret" data-key="' + esc(key) + '">' + (marker ? 'Replace key' : 'Set key') + '</button>')-- + (!pinned && marker ? ' <button type="button" class="btn btn-sm pai-quiet pai-quiet-danger" data-act="secret-remove" data-key="' + esc(key) + '">Remove</button>' : '') + '</div>';-- } else if (prop.enum && Array.isArray(prop.enum)) {-- control = '<select data-pai-control data-type="enum" data-key="' + esc(key) + '" aria-label="' + esc(title) + '"' + (pinned ? ' data-ov-native' : '') + dis + '>' + prop.enum.map((v) => '<option value="' + esc(v) + '"' + (String(value) === String(v) ? ' selected' : '') + '>' + esc(v) + '</option>').join('') + '</select>';-- } else if (type === 'boolean') {-- control = '<label class="pai-switch"><input type="checkbox" role="switch" data-ov-native data-pai-control data-type="boolean" data-key="' + esc(key) + '"' + (value ? ' checked' : '') + dis + '><span class="pai-track" aria-hidden="true"><span class="pai-knob"></span></span><span class="pai-switch-text">' + (value ? 'On' : 'Off') + '</span></label>';-- } else if (type === 'array') {++ const nest = nested ? ' data-nested="1"' : '';++ const placeholder = ['string', 'number'].includes(typeof prop['x-example']) ? ' placeholder="' + esc(prop['x-example']) + '"' : '';++ if (prop.enum && Array.isArray(prop.enum)) {++ return '<select data-pai-control data-type="enum" data-key="' + esc(path) + '"' + nest + ' aria-label="' + esc(title) + '"' + (pinned ? ' data-ov-native' : '') + dis + '>' + paiOptions(prop, value) + '</select>';++ }++ if (type === 'boolean') {++ return '<label class="pai-switch"><input type="checkbox" role="switch" data-ov-native data-pai-control data-type="boolean" data-key="' + esc(path) + '"' + nest + (value ? ' checked' : '') + dis + ' aria-label="' + esc(title) + '"><span class="pai-track" aria-hidden="true"><span class="pai-knob"></span></span><span class="pai-switch-text">' + (value ? 'On' : 'Off') + '</span></label>';++ }++ if (type === 'array') {const items = Array.isArray(value) ? value : [];-- control = '<div class="pai-chips" data-pai-chips>' + items.map((it) => paiChip(it, !pinned)).join('') + '</div>'++ return '<div class="pai-chips" data-pai-chips>' + items.map((it) => paiChip(it, !pinned)).join('') + '</div>'+ (pinned ? '' : '<span class="pai-list-add"><input data-pai-list-input placeholder="Add…" aria-label="Add to ' + esc(title) + '"><button type="button" class="btn btn-sm pai-quiet" data-act="list-add">Add</button></span>');-- } else if (type === 'object') {-- control = '<textarea data-pai-control data-type="object" data-key="' + esc(key) + '" rows="3" spellcheck="false" aria-label="' + esc(title) + '"' + dis + '>' + esc(JSON.stringify(value == null ? {} : value, null, 2)) + '</textarea>';-- } else if (prop['x-unit'] === 'nano_usd') {-- control = '<span class="pai-money-input"><span class="pai-prefix">$</span><input data-pai-control data-type="nano_usd" data-key="' + esc(key) + '" inputmode="decimal" value="' + esc(paiUsd(value)) + '" aria-label="' + esc(title) + '"' + dis + '></span>';-- } else if (type === 'integer') {++ }++ if (type === 'object') {++ // Raw JSON: only ever drawn inside Advanced settings (paiAdvanced).++ return '<textarea data-pai-control data-type="object" data-key="' + esc(path) + '"' + nest + ' rows="3" spellcheck="false" aria-label="' + esc(title) + '"' + dis + '>' + esc(JSON.stringify(value == null ? {} : value, null, 2)) + '</textarea>';++ }++ if (prop['x-unit'] === 'nano_usd') {++ return '<span class="pai-money-input"><span class="pai-prefix">$</span><input data-pai-control data-type="nano_usd" data-key="' + esc(path) + '"' + nest + ' inputmode="decimal" value="' + esc(paiUsd(value)) + '" aria-label="' + esc(title) + ' in dollars"' + dis + '></span>';++ }++ if (type === 'integer') {const unit = paiUnitLabel(prop['x-unit']);-- const input = '<input data-pai-control data-type="integer" data-key="' + esc(key) + '"' + (nullable ? ' data-nullable="1"' : '') + ' type="number" step="1" value="' + esc(value == null ? '' : value) + '" aria-label="' + esc(title) + '"' + dis + '>';-- control = unit ? '<span class="pai-unit-input">' + input + '<span class="pai-unit">' + esc(unit) + '</span></span>' : input;-- } else if (type === 'number') {-- control = '<input data-pai-control data-type="number" data-key="' + esc(key) + '"' + (nullable ? ' data-nullable="1"' : '') + ' type="number" step="any" value="' + esc(value == null ? '' : value) + '" aria-label="' + esc(title) + '"' + dis + '>';++ const input = '<input data-pai-control data-type="integer" data-key="' + esc(path) + '"' + nest + (nullable ? ' data-nullable="1"' : '') + ' type="number" step="1" value="' + esc(value == null ? '' : value) + '" aria-label="' + esc(title) + (unit ? ' in ' + esc(PAI_UNIT_WORDS[prop['x-unit']] || unit) : '') + '"' + placeholder + dis + '>';++ return unit ? '<span class="pai-unit-input">' + input + '<span class="pai-unit">' + esc(unit) + '</span></span>' : input;++ }++ if (type === 'number') {++ return '<input data-pai-control data-type="number" data-key="' + esc(path) + '"' + nest + (nullable ? ' data-nullable="1"' : '') + ' type="number" step="any" value="' + esc(value == null ? '' : value) + '" aria-label="' + esc(title) + '"' + placeholder + dis + '>';++ }++ return '<input data-pai-control data-type="string" data-key="' + esc(path) + '"' + nest + (nullable ? ' data-nullable="1"' : '') + ' value="' + esc(value == null ? '' : value) + '" aria-label="' + esc(title) + '"' + placeholder + dis + '>';++}++++function paiField(n, key, prop, pinned, pinName) {++ const value = n.values ? n.values[key] : undefined;++ const secret = paiIsSecret(n, key, prop);++ const danger = !!prop['x-danger'];++ const title = prop.title || paiHumanize(key);++ const description = prop.description || '';++ const types = Array.isArray(prop.type) ? prop.type : [prop.type];++ const type = types.find((t) => t !== 'null') || 'string';++ const money = /money|credit|spend|pay|price|usd|cost|cap/i.test(title + ' ' + description + ' ' + key) || prop['x-unit'] === 'nano_usd';++ const chips = (pinned ? '<span class="pai-chip-lock">From the settings file</span>' : '') + (danger ? '<span class="pai-chip-danger">' + (money ? 'Money' : 'Takes effect at once') + '</span>' : '') + (secret ? '<span class="pai-chip-secret">Key</span>' : '');++ let control;++ if (secret) {++ const m = (n.secret_markers && n.secret_markers[key]) || null;++ const set = !!(value && typeof value === 'object' && value.redacted === true);++ const shown = pinned ? 'From the server\'s settings file' + (pinName || prop['x-env'] ? ' (' + esc(pinName || prop['x-env']) + ')' : '')++ : set ? 'Entered here, locked' + (m && m.hint ? ' ••••' + esc(m.hint) : '') + (m && m.set_at ? ' · ' + esc(paiWhen(m.set_at)) : '') : 'Not set';++ control = '<div class="pai-secret"><span class="pai-marker">' + shown + '</span>'++ + (pinned ? '' : ' <button type="button" class="btn btn-sm pai-quiet" data-act="secret" data-key="' + esc(key) + '">' + (set ? 'Replace' : 'Enter it') + '</button>')++ + (!pinned && set ? ' <button type="button" class="btn btn-sm pai-quiet pai-quiet-danger" data-act="secret-remove" data-key="' + esc(key) + '">Remove</button>' : '') + '</div>';++ } else if (type === 'object' && prop.properties && typeof prop.properties === 'object' && Object.keys(prop.properties).length) {++ // An object whose fields the schema describes is a small form, not raw JSON.++ const inner = value && typeof value === 'object' && !Array.isArray(value) ? value : {};++ control = '<div class="pai-subform" data-pai-object data-key="' + esc(key) + '">' + Object.entries(prop.properties).map(([k, p]) => {++ const t = (p && p.title) || paiHumanize(k);++ return '<div class="pai-subfield"><span class="pai-subfield-label">' + esc(t) + ' <code>' + esc(k) + '</code></span>'++ + (p && p.description ? '<span class="pai-field-desc muted">' + esc(p.description) + '</span>' : '')++ + paiControl(key + '.' + k, p || {}, inner[k], { pinned, title: t, nested: true }) + '</div>';++ }).join('') + '</div>';} else {-- control = '<input data-pai-control data-type="string" data-key="' + esc(key) + '"' + (nullable ? ' data-nullable="1"' : '') + ' value="' + esc(value == null ? '' : value) + '" aria-label="' + esc(title) + '"' + dis + '>';++ control = paiControl(key, prop, value, { pinned, title });}-- const pinLine = pinned ? '<p class="pai-pin-line">Pinned by ' + esc(pinName || (prop['x-env'] || 'the environment')) + ' in the env file</p>' : '';-- return '<div class="pai-field" data-pai-field data-key="' + esc(key) + '" data-secret="' + (cls === 'secret' ? '1' : '0') + '" data-pinned="' + (pinned ? '1' : '0') + '">'++ const unitWords = prop['x-unit'] && prop['x-unit'] !== 'nano_usd' && PAI_UNIT_WORDS[prop['x-unit']] ? '<p class="pai-unit-line muted">In ' + esc(PAI_UNIT_WORDS[prop['x-unit']]) + '.</p>' : '';++ const pinLine = pinned ? '<p class="pai-pin-line">Set in the server\'s settings file (' + esc(pinName || prop['x-env'] || 'its env file') + '); change it there. ' + paiWhat('settings-file') + '</p>' : '';++ return '<div class="pai-field" data-pai-field data-key="' + esc(key) + '" data-secret="' + (secret ? '1' : '0') + '" data-pinned="' + (pinned ? '1' : '0') + '">'+ '<div class="pai-field-label"><label>' + esc(title) + ' <code>' + esc(key) + '</code></label>' + chips + '</div>'+ (description ? '<p class="pai-field-desc muted">' + esc(description) + '</p>' : '')-- + '<div class="pai-control">' + control + pinLine + '</div></div>';++ + '<div class="pai-control">' + control + unitWords + pinLine + '</div></div>';}function paiChip(value, removable = true) {@@@ -1148,37 +1293,63 @@ async function paiCardClick(ev) {}}++/** One control's value, or { error }. */++function paiReadControl(control, label) {++ const type = control.dataset.type;++ if (type === 'boolean') return { value: control.checked };++ if (control.dataset.nullable === '1' && control.value === '') return { value: null };++ if (type === 'nano_usd') {++ const parsed = paiParseUsd(control.value);++ return parsed.error ? { error: label + ': ' + parsed.error } : { value: parsed.nano };++ }++ if (type === 'integer') {++ const number = Number(control.value);++ return Number.isInteger(number) && control.value !== '' ? { value: number } : { error: label + ': enter a whole number' };++ }++ if (type === 'number') {++ const number = Number(control.value);++ return Number.isFinite(number) && control.value !== '' ? { value: number } : { error: label + ': enter a number' };++ }++ if (type === 'object') {++ try { return { value: JSON.parse(control.value) }; } catch { return { error: label + ': the JSON is not valid' }; }++ }++ if (type === 'enum') {++ // An option's value is the enum value as text; a non-string one is its JSON.++ const select = control;++ try { const v = JSON.parse(select.value); if (typeof v !== 'string') return { value: v }; } catch { /* a plain string */ }++ return { value: select.value };++ }++ return { value: control.value };++}++function paiCollect(card) {const values = {};for (const field of card.querySelectorAll('[data-pai-field]')) {if (field.dataset.pinned === '1' || field.dataset.secret === '1') continue;-- const control = field.querySelector('[data-pai-control]');-- if (!control) continue;const key = field.dataset.key;if (!key) continue;-- if (control.dataset.type === 'boolean') values[key] = control.checked;-- else if (control.dataset.nullable === '1' && control.value === '') values[key] = null;-- else if (control.dataset.type === 'nano_usd') {-- const parsed = paiParseUsd(control.value);-- if (parsed.error) return { error: control.dataset.key + ': ' + parsed.error };-- values[key] = parsed.nano;-- } else if (control.dataset.type === 'integer') {-- const number = Number(control.value);-- if (!Number.isInteger(number)) return { error: key + ': enter a whole number' };-- values[key] = number;-- } else if (control.dataset.type === 'number') {-- const number = Number(control.value);-- if (!Number.isFinite(number)) return { error: key + ': enter a number' };-- values[key] = number;-- } else if (control.dataset.type === 'enum' || control.dataset.type === 'string') values[key] = control.value;-- else if (control.dataset.type === 'object') {-- try { values[key] = JSON.parse(control.value); } catch { return { error: key + ': the JSON is not valid' }; }++ const label = (field.querySelector('.pai-field-label label') || {}).textContent || key;++ const object = field.querySelector('[data-pai-object]');++ if (object) {++ const out = {};++ for (const control of object.querySelectorAll('[data-pai-control][data-nested="1"]')) {++ const child = control.dataset.key.slice(key.length + 1);++ const read = paiReadControl(control, label.trim() + ' · ' + child);++ if (read.error) return { error: read.error };++ out[child] = read.value;++ }++ values[key] = out;++ continue;}-- }-- for (const field of card.querySelectorAll('[data-pai-field]')) {if (field.querySelector('[data-pai-chips]')) {-- values[field.dataset.key] = [...field.querySelectorAll('.pai-chip')].map((chip) => JSON.parse(chip.dataset.value));++ values[key] = [...field.querySelectorAll('.pai-chip')].map((chip) => JSON.parse(chip.dataset.value));++ continue;}++ const control = field.querySelector('[data-pai-control]');++ if (!control) continue;++ const read = paiReadControl(control, label.trim());++ if (read.error) return { error: read.error };++ values[key] = read.value;}return { values };}@@@ -1188,13 +1359,17 @@ async function paiSaveCard(card, n) {if (collected.error) return toast(collected.error, 'error');const changed = {};for (const [key, value] of Object.entries(collected.values)) {-- if (JSON.stringify(value) !== JSON.stringify(n.values ? n.values[key] : undefined)) changed[key] = value;++ const before = n.values ? n.values[key] : undefined;++ // An object drawn as a form keeps the members it did not draw.++ const next = value && typeof value === 'object' && !Array.isArray(value) && before && typeof before === 'object' && !Array.isArray(before) ? { ...before, ...value } : value;++ if (JSON.stringify(next) !== JSON.stringify(before)) changed[key] = next;}if (!Object.keys(changed).length) return toast('Nothing to save', 'info');const props = (n.schema && n.schema.properties) || {};const danger = Object.keys(changed).filter((key) => props[key] && props[key]['x-danger']);if (danger.length) {-- const lines = danger.map((key) => (props[key].title || key) + ': ' + (props[key].description || 'This change takes effect at once.') + ' Now: ' + (changed[key] ? 'on' : 'off') + '.');++ const lines = danger.map((key) => (props[key].title || paiHumanize(key)) + ': ' + (props[key].description || 'This takes effect at once.') + ' It will be ' + (typeof changed[key] === 'boolean' ? (changed[key] ? 'on' : 'off') : (props[key]['x-unit'] === 'nano_usd' ? '$' + paiUsd(changed[key]) : JSON.stringify(changed[key]))) + '.');++ lines.push('To undo it, change it back here, or roll back to the previous version under History.');const ok = await paiConfirm('Save this money setting?', lines);if (!ok) return;}@@@ -1214,9 +1389,10 @@ async function paiSaveCard(card, n) {}function paiSecretDialog(n, key) {++ const prop = (n.schema && n.schema.properties && n.schema.properties[key]) || {};const dlg = document.createElement('dialog');dlg.className = 'pai-dialog';-- dlg.innerHTML = '<form method="dialog" class="pai-form"><h3>' + esc(key) + '</h3><p class="muted">The value is written once, sealed by ' + esc(paiLabel(n.service)) + '; it is never shown again.</p>'++ dlg.innerHTML = '<form method="dialog" class="pai-form"><h3>' + esc(prop.title || paiHumanize(key)) + ' <code class="pai-mkey">' + esc(key) + '</code></h3><p class="muted">The value is written once and locked by ' + esc(paiLabel(n.service)) + '; it is never shown again.</p>'+ '<div id="pai-secret-field"></div><div class="pai-dialog-actions"><button type="button" class="btn" data-act="cancel">Cancel</button><button type="submit" class="btn btn-primary" data-act="save">Save key</button></div></form>';const field = document.createElement('div');field.className = 'ov-field';@@@ -1263,7 +1439,8 @@ function paiSecretDialog(n, key) {}async function paiRemoveSecret(n, key) {-- const ok = await paiConfirm('Remove ' + key + '?', [paiLabel(n.service) + ' will not have this value until it is entered again.']);++ const prop = (n.schema && n.schema.properties && n.schema.properties[key]) || {};++ const ok = await paiConfirm('Remove ' + (prop.title || paiHumanize(key)) + '?', [paiLabel(n.service) + ' will not have this key until it is entered again; what uses it stops working.', 'It cannot be shown again, so keep a copy where you got it.']);if (!ok) return;try {await paiApi('/' + encodeURIComponent(n.service) + '/' + encodeURIComponent(n.namespace) + '/secrets/' + encodeURIComponent(key), { method: 'DELETE', body: { reason: 'Removed from the admin panel' } });@@@ -1285,12 +1462,13 @@ async function paiHistoryDialog(n) {const dlg = document.createElement('dialog');dlg.className = 'pai-dialog pai-dialog-wide';const rows = snapshots.map((s) => {-- const actor = s.activated_by && s.activated_by.id ? s.activated_by.id : (s.created_by && s.created_by.id ? s.created_by.id : '');++ const actor = paiWho(s.activated_by || s.created_by);const current = s.state === 'active' || s.revision === n.revision;return '<tr><td>' + esc(s.revision) + '</td><td>' + esc(paiWhen(s.activated_at || s.created_at)) + '</td><td>' + esc(actor || '—') + '</td><td>' + esc(s.reason || '—') + '</td><td>' + (current ? '<span class="muted">Current</span>' : '<button type="button" class="btn btn-sm" data-rollback="' + esc(s.revision) + '">Roll back</button>') + '</td></tr>';}).join('');-- dlg.innerHTML = '<div class="pai-form"><h3>History · ' + esc(n.namespace) + '</h3>'-- + (snapshots.length ? '<div class="pai-table-wrap"><table class="pai-table"><thead><tr><th>Revision</th><th>When</th><th>By</th><th>Reason</th><th></th></tr></thead><tbody>' + rows + '</tbody></table></div>' : '<p class="muted">No revisions yet.</p>')++ dlg.innerHTML = '<div class="pai-form"><h3>History · ' + esc((n.schema && n.schema.title) || n.namespace) + ' <code class="pai-mkey">' + esc(n.namespace) + '</code></h3>'++ + '<p class="muted">Every saved version, newest first. Rolling back makes an older version current again, as a new version; nothing is deleted.</p>'++ + (snapshots.length ? '<div class="pai-table-wrap"><table class="pai-table"><thead><tr><th>Version</th><th>When</th><th>By</th><th>Why</th><th></th></tr></thead><tbody>' + rows + '</tbody></table></div>' : '<p class="muted">No saved versions yet: these are the defaults.</p>')+ '<div class="pai-dialog-actions"><button type="button" class="btn" data-act="close">Close</button></div></div>';document.body.appendChild(dlg);dlg.addEventListener('click', async (ev) => {@@@ -1298,7 +1476,7 @@ async function paiHistoryDialog(n) {const rollback = ev.target.closest('[data-rollback]');if (!rollback) return;const to = Number(rollback.dataset.rollback);-- const ok = await paiConfirm('Roll back to revision ' + to + '?', ['The namespace returns to that revision. Nothing is deleted; the rollback is recorded as a new revision.']);++ const ok = await paiConfirm('Roll back to version ' + to + '?', ['These settings return to version ' + to + '. Nothing is deleted: the rollback is kept as a new version, so you can roll forward again.']);if (!ok) return;rollback.disabled = true;try {@@@ -1332,6 +1510,7 @@ function paiConfirm(title, lines) {});}++/** Per service, in words: locked with its own lock key, not locked, or not published yet. */function paiRenderVault() {const box = document.getElementById('pai-vault-list');if (!box) return;@@@ -1341,14 +1520,16 @@ function paiRenderVault() {let status;let hint = '';if (state.answered && state.sealed === true) {-- status = '<span class="pai-state is-ok">Sealed' + (state.kek ? ' · ' + esc(state.kek) : '') + '</span>';++ status = '<span class="pai-state is-ok">Keys are locked</span>' + (state.kek ? ' <code class="pai-mkey">' + esc(state.kek) + '</code>' : '');} else if (state.answered && state.sealed === false) {-- status = '<span class="pai-state is-bad">Not sealed</span>';-- hint = '<div class="pai-hint">the service needs openvibe-shared 4.110 or later and OV_CONFIG_KEKS</div>';++ status = '<span class="pai-state is-bad">Keys are stored unlocked</span>';++ hint = '<div class="pai-hint">Add its lock key (OV_CONFIG_KEKS and OV_CONFIG_KEK_CURRENT) to its settings file on the server, with openvibe-shared 4.110 or later.</div>';++ } else if (state.answered && !state.published) {++ status = '<span class="pai-state is-muted">' + name + ' has not published settings yet</span>';} else if (state.answered) {-- status = '<span class="pai-state is-muted">No settings yet</span>';++ status = '<span class="pai-state is-muted">No keys yet</span>';} else {-- status = '<span class="pai-state is-muted">Did not answer</span>';++ status = '<span class="pai-state is-muted">' + name + ' has not published settings yet</span>';}return '<div class="pai-vault-row"><span class="pai-vault-name">' + name + '</span>' + status + hint + '</div>';});@@@ -5,6 +5,8 @@tabLoaders map in admin/boot.js (loaded last). No ES modules.═══════════════════════════════════════════════════════════════ */const tabLoaders = {++ setup: loadSetup,++ glossary: loadGlossary,dashboard: loadDashboard,users: loadUsers,streams: loadStreams,@@@ -80,7 +80,8 @@ let currentTab = 'dashboard';// Tabs restricted to the network owner (API keys / money / infrastructure / core settings).// NOTE: 'ai' is NOT here — admins may use its analytics/explorer tools; only the// AI configuration form inside it is owner-gated (see loadAi).--const OWNER_ONLY_TABS = new Set(['deploy', 'domains', 'payments', 'cashouts', 'storage', 'email', 'settings', 'grants', 'eventsops']);++// 'setup' is the owner's checklist of keys and accounts (js/admin/setup.js); its API answers 403 to anyone else too.++const OWNER_ONLY_TABS = new Set(['setup', 'deploy', 'domains', 'payments', 'cashouts', 'storage', 'email', 'settings', 'grants', 'eventsops']);function showTab(id, btn) {if (!tabLoaders[id]) id = 'dashboard';nnew file mode 100644@@@ -0,0 +1,99 @@++/* ═══════════════════════════════════════════════════════════════++ OpenVibe.Network — admin panel (/admin): the glossary (/admin#glossary).++ The few words the panel cannot avoid, each in plain words (OWNER-SETUP-AND-ADMIN-CLARITY-DESIGN, 2026-11-07,++ rule 9). A small "What does this mean?" link next to a word opens its line in place; the whole list is the++ Glossary tab. Classic script, global scope: it relies on admin/core.js's esc() and is loaded before ai.js and++ setup.js. No ES modules.++ ═══════════════════════════════════════════════════════════════ */++const GLOSSARY = [++ { id: 'credits', word: 'Credits', text: 'What people spend on AI at OpenVibe. People buy them with money, a request uses some, and what a request costs depends on the model. Credits are kept by OpenVibe.Billing, not by the AI providers.' },++ { id: 'byok', word: 'Their own key (BYOK)', text: '"Bring your own key": a person enters their own key for a provider, and their requests are paid by that key instead of by OpenVibe or by Credits.' },++ { id: 'resale', word: 'Resale', text: 'Letting people use a provider\'s models through OpenVibe and pay OpenVibe for it. Some providers\' terms forbid this; a provider whose terms are not checked yet is treated as "Do not sell".' },++ { id: 'cache', word: 'Cache', text: 'A saved copy of an earlier answer or of the start of a long prompt. Reading from the cache is cheaper and faster than asking again, so providers price it lower.' },++ { id: 'quota', word: 'Quota', text: 'A limit on how much someone may use in a period, for example requests per day. When it is reached, more requests wait or are refused until the period ends.' },++ { id: 'workflow', word: 'Workflow (saved AI task)', text: 'A saved AI task with its steps and settings, which other parts of OpenVibe run by name. A prompt template is the text it starts from; a routing rule decides which model answers.' },++ { id: 'lock-key', word: 'Lock key', text: 'The key a service locks every stored key with (its "KEK", in the server\'s settings file). Without it, keys are stored unlocked. Nobody can read a locked key back, not even the owner.' },++ { id: 'settings-file', word: 'Server\'s settings file', text: 'The private file on the server that holds a service\'s settings (its env file). A setting it holds wins over what is entered here, and can only be changed on the server.' },++ { id: 'fresh-sign-in', word: 'Confirm it\'s you', text: 'Changing a key or a money setting needs a sign-in from the last ten minutes. If yours is older, OpenVibe asks for your password again and brings you back.' },++ { id: 'health', word: 'Health (paused provider)', text: 'After several errors in a row OpenVibe pauses a provider for a short while and uses the next one, then tries it again. "Paused" is that wait; "Testing again" is the first try after it.' },++ { id: 'tokens', word: 'Million words (tokens)', text: 'Providers price text per million tokens; a token is about three quarters of a word. "Read" (input) is what is sent to the model, "written" (output) is what it answers.' },++ { id: 'version', word: 'Saved version', text: 'Every change to a group of settings is kept as a numbered version, with who made it and why. History shows them; Roll back makes an older one current again, as a new version.' },++ { id: 'setup-states', word: 'Needed, Not working, Connected, Optional', text: 'On Setup: Needed means something waits on it; Not working means it is set but its last test failed; Connected means it is set and not failing; Optional means nothing waits on it.' },++];++++function glossaryEntry(id) {++ return GLOSSARY.find((g) => g.id === id) || null;++}++++/** 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>';++}++++function glossaryPopup(id, anchor) {++ const entry = glossaryEntry(id);++ if (!entry) return;++ const dlg = document.createElement('dialog');++ dlg.className = 'pai-dialog ov-what-dialog';++ const form = document.createElement('div');++ form.className = 'pai-form';++ const h = document.createElement('h3');++ h.textContent = entry.word;++ const p = document.createElement('p');++ p.className = 'ov-what-text';++ 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);++ form.append(h, p, actions);++ dlg.append(form);++ document.body.appendChild(dlg);++ dlg.addEventListener('close', () => { dlg.remove(); if (anchor && anchor.focus) anchor.focus(); });++ dlg.showModal();++}++++/** 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]');++ if (!link || ev.button !== 0 || ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return;++ 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);++}nnew file mode 100644@@@ -0,0 +1,355 @@++/* ═══════════════════════════════════════════════════════════════++ OpenVibe.Network — admin panel (/admin): the owner's Setup tab (/admin#setup).++ One checklist of everything only the owner can give the network (OWNER-SETUP-AND-ADMIN-CLARITY-DESIGN, 2026-11-07,++ section 1). The rows come from GET /api/admin/setup (server/admin/setup.js); saving, removing and testing a key go++ through the owner settings proxy (/api/admin/ai/config, server/admin/ai-config-proxy.js), with its fresh sign-in,++ vault, audit and alerts. A key is only ever written: its input is built as a DOM node, cleared and removed after++ saving, and never filled from anything the server sends.++ Classic script, global scope: it relies on admin/core.js (api, esc, toast, timeAgo, currentUser), glossary.js++ (glossaryLink) and ai.js (paiConfirm, paiUsd, paiParseUsd). No ES modules.++ ═══════════════════════════════════════════════════════════════ */++const SETUP = { data: null, loading: false };++const SETUP_STATE = {++ needed: { label: 'Needed', tone: 'bad' },++ not_working: { label: 'Not working', tone: 'warn' },++ connected: { label: 'Connected', tone: 'ok' },++ optional: { label: 'Optional', tone: 'muted' },++};++const SETUP_SIGN_IN = '/login?reauth=1&return=%2Fadmin%23setup';++++function setupPlural(n, one, many) { return n + ' ' + (n === 1 ? one : many); }++++/** "6 things need you · 2 not working · 31 connected · 4 optional" */++function setupSummary(c) {++ const parts = [++ c.needed === 1 ? '1 thing needs you' : c.needed + ' things need you',++ c.not_working + ' not working',++ c.connected + ' connected',++ ];++ if (c.optional) parts.push(c.optional + ' optional');++ return parts.join(' · ');++}++++/** "Card payments" → "card payments", "GPUs" stays: the feature after "Needed for". */++function setupFor(text) {++ const t = String(text || '');++ return /^[A-Z][a-z]/.test(t) ? t.charAt(0).toLowerCase() + t.slice(1) : t;++}++++function setupWhen(iso) {++ if (!iso) return '';++ try { return timeAgo(iso); } catch { return ''; }++}++++/** The state line in words: what waits, what failed, what is set. */++function setupStateLine(row) {++ if (row.kind === 'task') {++ if (row.done) return 'Done' + (row.done.at ? ' on ' + row.done.at : '') + (row.done.by ? ' by @' + row.done.by : '') + '.';++ return row.required_for ? 'Needed for ' + setupFor(row.required_for) + '.' : 'Nothing waits on it; do it when you can.';++ }++ if (row.state === 'needed') return 'Needed for ' + setupFor(row.required_for) + '.';++ if (row.state === 'optional') return 'Not set. Nothing waits on it.';++ const parts = [];++ if (row.pinned) parts.push('From the server\'s settings file' + (row.pin_env ? ' (' + row.pin_env + ')' : ''));++ else if (row.secret) parts.push('Entered here, locked' + (row.hint ? ' · ••••' + row.hint : '') + (row.set_at ? ' · set ' + row.set_at : ''));++ else if (typeof row.value === 'boolean') parts.push(row.value ? 'On' : 'Off');++ else if (row.value !== null && row.value !== undefined) parts.push('Set to ' + (row.unit === 'nano_usd' ? '$' + paiUsd(row.value) : String(row.value)));++ else parts.push('Set');++ if (row.state === 'not_working' && row.last_test) parts.push('the last test failed ' + setupWhen(row.last_test.at) + ': ' + row.last_test.message);++ else if (row.last_test && row.last_test.passed) parts.push('the last test passed ' + setupWhen(row.last_test.at));++ else if (row.testable) parts.push('not tested yet');++ return parts.join('; ') + '.';++}++++async function loadSetup() {++ const root = document.getElementById('setup-content');++ if (!root) return;++ if (!currentUser || !currentUser.is_owner) { root.innerHTML = ''; return; }++ if (SETUP.loading) return;++ SETUP.loading = true;++ if (!SETUP.data) root.innerHTML = '<div class="loading">Asking every service what it needs from you…</div>';++ try {++ SETUP.data = await api('/api/admin/setup');++ setupRender(root);++ } catch (err) {++ root.innerHTML = setupHead('') + '<p class="pai-error">Setup could not be read: ' + esc(err.message || 'the request failed') + '</p>';++ } finally {++ SETUP.loading = false;++ }++}++++function setupHead(summary) {++ return '<header class="setup-head">'++ + '<h2><i class="fa-solid fa-list-check" aria-hidden="true"></i> Setup</h2>'++ + '<p class="muted">Everything only you can give OpenVibe: keys, accounts, limits and a few registrations, from every part of the network. Come here when something says it needs you, or when you change a key or an account.</p>'++ + (summary ? '<p class="setup-summary" role="status">' + summary + '</p>' : '')++ + '<p class="setup-help muted">Keys are written once and locked; nothing here can show one again. ' + glossaryLink('setup-states', 'What do Needed, Not working, Connected and Optional mean?') + '</p>'++ + '<div id="setup-fresh" class="pai-fresh" hidden><i class="fa-solid fa-lock" aria-hidden="true"></i> <span>Confirm it\'s you to change keys and money settings.</span> <a class="btn btn-primary" href="' + SETUP_SIGN_IN + '">Sign in again</a> ' + glossaryLink('fresh-sign-in') + '</div>'++ + '</header>';++}++++function setupRender(root) {++ const d = SETUP.data;++ const c = d.counts;++ const summary = '<strong>' + esc(c.needed === 1 ? '1 thing needs you' : c.needed + ' things need you') + '</strong> · ' + esc(setupSummary(c).split(' · ').slice(1).join(' · '));++ const groups = d.groups.map((g) => {++ const counts = [];++ if (g.counts.needed) counts.push('<span class="setup-count is-bad">' + setupPlural(g.counts.needed, 'needs you', 'need you') + '</span>');++ if (g.counts.not_working) counts.push('<span class="setup-count is-warn">' + g.counts.not_working + ' not working</span>');++ return '<section class="setup-group" id="setup-group-' + esc(g.id) + '" aria-labelledby="setup-group-h-' + esc(g.id) + '">'++ + '<h3 id="setup-group-h-' + esc(g.id) + '">' + esc(g.label) + ' ' + counts.join(' ') + '</h3>'++ + '<ol class="setup-rows">' + g.rows.map(setupRowHtml).join('') + '</ol></section>';++ }).join('');++ const empty = d.empty_groups && d.empty_groups.length++ ? '<p class="setup-empty muted">Nothing listed yet for ' + esc(d.empty_groups.map((g) => g.label).join(', ')) + ': the services that hold these have not published their settings yet.</p>'++ : '';++ const quiet = (d.services || []).filter((s) => s.note);++ const reach = quiet.length++ ? '<details class="pai-more setup-services"><summary>Services that ask nothing yet (' + quiet.length + ')</summary><ul>' + quiet.map((s) => '<li>' + esc(s.note) + '</li>').join('') + '</ul></details>'++ : '';++ root.innerHTML = setupHead(summary)++ + (d.rows.length ? groups : '<p class="muted">No service asks anything of you yet. When one publishes its settings, its keys appear here.</p>')++ + empty + reach++ + '<p class="setup-foot muted">Network\'s own email and Discord keys are on the <a href="/admin/settings">Settings</a> tab; the AI providers\' keys are also on the <a href="/admin/ai">AI</a> tab.</p>';++ for (const li of root.querySelectorAll('[data-setup-row]')) setupAttachControls(li);++ root.onclick = setupClick;++ if (window.OVForms && window.OVForms.enhance) window.OVForms.enhance(root);++}++++function setupRowHtml(row) {++ const st = SETUP_STATE[row.state] || SETUP_STATE.optional;++ // A task is Done; a key or account is Connected; a plain setting (a switch, a cap, an id) is only Set: a switch++ // that is Off is not "connected" to anything.++ const stateLabel = row.state !== 'connected' ? st.label : row.kind === 'task' ? 'Done' : row.secret ? st.label : 'Set';++ const where = row.get_it_at++ ? '<p class="setup-where"><a href="' + esc(row.get_it_at) + '" target="_blank" rel="noopener noreferrer">' + (row.kind === 'task' ? 'Where to do it' : 'Where to get it') + ' <i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i></a>' + (row.get_it_how ? ' <span>' + esc(row.get_it_how) + '</span>' : '') + '</p>'++ : (row.get_it_how ? '<p class="setup-where"><span>' + esc(row.get_it_how) + '</span></p>' : '');++ const what = row.description || row.unlocks || '';++ const from = row.kind === 'task'++ ? 'Kept by OpenVibe.Network'++ : esc(row.service_name) + ' <code>' + esc(row.namespace) + '</code> <code>' + esc(row.key) + '</code>';++ return '<li class="setup-row is-' + esc(row.state) + '" data-setup-row data-id="' + esc(row.id) + '">'++ + '<div class="setup-row-main">'++ + '<div class="setup-row-title"><strong>' + esc(row.title) + '</strong> <span class="setup-state is-' + st.tone + '">' + esc(stateLabel) + '</span>'++ + (row.danger ? ' <span class="pai-chip-danger">Money</span>' : '') + '</div>'++ + (what ? '<p class="setup-what">' + esc(what) + '</p>' : '')++ + '<p class="setup-state-line is-' + st.tone + '">' + esc(setupStateLine(row)) + '</p>'++ + where++ + '<p class="setup-from">' + from + '</p>'++ + '</div>'++ + '<div class="setup-row-actions" data-setup-actions></div>'++ + '<p class="setup-result" data-setup-result role="status" hidden></p>'++ + '</li>';++}++++function setupRowOf(el) {++ const li = el.closest('[data-setup-row]');++ const row = li && SETUP.data ? SETUP.data.rows.find((r) => r.id === li.dataset.id) : null;++ return { li, row };++}++++function setupButton(text, act, cls) {++ const b = document.createElement('button');++ b.type = 'button';++ b.className = 'btn ' + (cls || 'pai-quiet');++ b.dataset.act = act;++ b.textContent = text;++ return b;++}++++/** The input and buttons of one row, as DOM nodes: a key's input is never part of an HTML string. */++function setupAttachControls(li) {++ const { row } = setupRowOf(li);++ const box = li.querySelector('[data-setup-actions]');++ if (!row || !box) return;++ box.textContent = '';++ if (row.kind === 'task') {++ box.append(row.done ? setupButton('Mark not done', 'undo') : setupButton('Mark done', 'done', 'btn-primary'));++ return;++ }++ if (row.pinned) {++ const note = document.createElement('p');++ note.className = 'setup-pinned muted';++ note.textContent = 'Change it in the server\'s settings file.';++ box.append(note);++ if (row.testable && row.set) box.append(setupButton('Test', 'test'));++ return;++ }++ const form = document.createElement('form');++ form.className = 'setup-input';++ form.dataset.setupForm = '1';++ const label = document.createElement('label');++ const inputId = 'setup-in-' + row.id.replace(/[^a-z0-9]/gi, '-');++ label.htmlFor = inputId;++ label.className = 'setup-input-label';++ const input = document.createElement('input');++ input.id = inputId;++ input.autocomplete = 'off';++ input.spellcheck = false;++ if (row.secret) {++ input.type = 'password';++ label.textContent = row.set ? 'Replace it (write-only)' : 'Paste it here (write-only)';++ input.placeholder = row.set ? 'A new value replaces the old one' : 'Paste the value';++ } else if (row.type === 'boolean') {++ input.type = 'checkbox';++ input.checked = row.value === true;++ label.textContent = 'On';++ } else if (row.unit === 'nano_usd') {++ input.type = 'text';++ input.inputMode = 'decimal';++ label.textContent = 'Amount in dollars';++ input.value = row.value != null ? paiUsd(row.value) : '';++ input.placeholder = '0.00';++ } else if (row.type === 'integer' || row.type === 'number') {++ input.type = 'number';++ input.step = row.type === 'integer' ? '1' : 'any';++ label.textContent = 'Value' + (row.unit ? ' (' + ({ ms: 'milliseconds', seconds: 'seconds', bps: 'bits per second' }[row.unit] || row.unit) + ')' : '');++ input.value = row.value != null ? String(row.value) : '';++ } else {++ input.type = 'text';++ label.textContent = row.set ? 'Change it' : 'Enter it here';++ input.value = row.value != null ? String(row.value) : '';++ if (row.example != null) input.placeholder = String(row.example);++ }++ input.dataset.setupInput = '1';++ const save = setupButton(row.set ? 'Save' : 'Save', 'save', 'btn-primary');++ save.type = 'submit';++ form.append(label, input, save);++ form.addEventListener('submit', (ev) => { ev.preventDefault(); setupSave(li, row, input, save); });++ box.append(form);++ const more = document.createElement('div');++ more.className = 'setup-more-actions';++ if (row.testable && row.set) more.append(setupButton('Test', 'test'));++ if (row.set) more.append(setupButton('Remove', 'remove', 'pai-quiet pai-quiet-danger'));++ if (more.childNodes.length) box.append(more);++}++++function setupFresh(err) {++ if (!(err && err.status === 403 && err.body && err.body.code === 'config.fresh_required')) return false;++ const el = document.getElementById('setup-fresh');++ if (el) {++ const link = el.querySelector('a.btn');++ // The proxy's link returns to the AI tab; Setup's returns here.++ if (link && err.body.sign_in_url) link.href = String(err.body.sign_in_url).replace(/%2Fadmin%23ai$/i, '%2Fadmin%23setup');++ el.hidden = false;++ el.scrollIntoView({ block: 'nearest' });++ }++ return true;++}++++function setupShowResult(li, text, tone) {++ const p = li.querySelector('[data-setup-result]');++ if (!p) return;++ p.textContent = text;++ p.className = 'setup-result is-' + tone;++ p.hidden = false;++}++++const setupConfigPath = (row) => '/api/admin/ai/config/' + encodeURIComponent(row.service) + '/' + encodeURIComponent(row.namespace);++++async function setupSave(li, row, input, button) {++ const isSecret = input.type === 'password';++ let body;++ if (isSecret) {++ if (!input.value) { input.focus(); return; }++ } else if (input.type === 'checkbox') {++ body = { values: { [row.key]: input.checked }, merge: true, reason: 'Set on Setup' };++ } else if (row.unit === 'nano_usd') {++ const parsed = paiParseUsd(input.value);++ if (parsed.error) return setupShowResult(li, parsed.error, 'bad');++ body = { values: { [row.key]: parsed.nano }, merge: true, reason: 'Set on Setup' };++ } else if (input.type === 'number') {++ const n = Number(input.value);++ if (input.value === '' || !Number.isFinite(n) || (row.type === 'integer' && !Number.isInteger(n))) return setupShowResult(li, row.type === 'integer' ? 'Enter a whole number.' : 'Enter a number.', 'bad');++ body = { values: { [row.key]: n }, merge: true, reason: 'Set on Setup' };++ } else {++ if (!input.value.trim()) { input.focus(); return; }++ body = { values: { [row.key]: input.value.trim() }, merge: true, reason: 'Set on Setup' };++ }++ if (row.danger) {++ const ok = await paiConfirm('Save ' + row.title + '?', [(row.unlocks || row.description || 'This takes effect at once.'), 'It takes effect at once. To undo it, change it back here or roll back its version on the AI tab.']);++ if (!ok) { if (isSecret) input.value = ''; return; }++ }++ button.disabled = true;++ const was = button.textContent;++ button.textContent = 'Saving…';++ try {++ if (isSecret) {++ const value = input.value;++ input.value = '';++ await api(setupConfigPath(row) + '/secrets/' + encodeURIComponent(row.key), { method: 'PUT', body: { value, reason: 'Entered on Setup' } });++ } else {++ await api(setupConfigPath(row), { method: 'POST', body });++ }++ toast(row.title + ' saved', 'success');++ await loadSetup();++ } catch (err) {++ if (!setupFresh(err)) setupShowResult(li, err.message || 'It was not saved.', 'bad');++ button.disabled = false;++ button.textContent = was;++ } finally {++ // A key must not stay in the page, whatever happened.++ if (isSecret) input.value = '';++ }++}++++async function setupClick(ev) {++ const button = ev.target.closest('button[data-act]');++ if (!button || button.type === 'submit') return;++ const { li, row } = setupRowOf(button);++ if (!row) return;++ const act = button.dataset.act;++ if (act === 'test') return setupTest(li, row, button);++ if (act === 'remove') return setupRemove(li, row, button);++ if (act === 'done' || act === 'undo') return setupTask(li, row, button, act === 'done');++}++++async function setupTest(li, row, button) {++ button.disabled = true;++ button.textContent = 'Testing…';++ setupShowResult(li, 'Asking ' + row.service_name + ' to try it…', 'muted');++ try {++ const r = await api(setupConfigPath(row) + '/test/' + encodeURIComponent(row.key), { method: 'POST', body: {} });++ setupShowResult(li, (r.passed ? 'It works: ' : 'It did not work: ') + r.message, r.passed ? 'ok' : 'bad');++ // Keep the answer on screen while the row's state catches up.++ const keep = li.querySelector('[data-setup-result]').textContent;++ await loadSetup();++ const again = document.querySelector('[data-setup-row][data-id="' + CSS.escape(row.id) + '"]');++ if (again) setupShowResult(again, keep, r.passed ? 'ok' : 'bad');++ } catch (err) {++ const msg = err && err.body && err.body.code === 'config.test_not_available'++ ? row.service_name + ' cannot test this yet.'++ : (err.message || 'The test did not run.');++ setupShowResult(li, msg, 'bad');++ button.disabled = false;++ button.textContent = 'Test';++ }++}++++async function setupRemove(li, row, button) {++ const stops = row.required_for ? row.required_for + ' stops working until a new value is entered.' : 'Nothing waits on it; it can be entered again at any time.';++ const ok = await paiConfirm('Remove ' + row.title + '?', [stops, row.secret ? 'The key is deleted from ' + row.service_name + '. It cannot be shown again, so keep a copy where you got it.' : 'Its earlier value stays in the history, where it can be rolled back.']);++ if (!ok) return;++ button.disabled = true;++ try {++ if (row.secret) await api(setupConfigPath(row) + '/secrets/' + encodeURIComponent(row.key), { method: 'DELETE', body: { reason: 'Removed on Setup' } });++ else await api(setupConfigPath(row), { method: 'POST', body: { values: {}, unset: [row.key], merge: true, reason: 'Removed on Setup' } });++ toast(row.title + ' removed', 'success');++ await loadSetup();++ } catch (err) {++ if (!setupFresh(err)) setupShowResult(li, err.message || 'It was not removed.', 'bad');++ button.disabled = false;++ }++}++++async function setupTask(li, row, button, done) {++ button.disabled = true;++ try {++ await api('/api/admin/setup/tasks/' + encodeURIComponent(row.key), { method: 'POST', body: { done } });++ toast(done ? row.title + ': done' : row.title + ': not done', 'success');++ await loadSetup();++ } catch (err) {++ setupShowResult(li, err.message || 'It was not saved.', 'bad');++ button.disabled = false;++ }++}@@@ -12,6 +12,7 @@* POST /api/admin/ai/config/:service/:namespace/rollback roll back to a revision* PUT /api/admin/ai/config/:service/:namespace/secrets/:key set a secret (common.config-secret-put@1)* DELETE /api/admin/ai/config/:service/:namespace/secrets/:key remove a secret++ * POST /api/admin/ai/config/:service/:namespace/test/:key the service tries the stored value (Setup's Test)* GET|POST /api/admin/ai/config/providers AI's provider registry (ai.provider.manage)* PATCH /api/admin/ai/config/providers/:key one provider* PUT|DELETE /api/admin/ai/config/providers/:key/key its write-only key (bound to the provider origin)@@@ -25,10 +26,14 @@** Network keeps no value: the body is forwarded and dropped, never written to a table or a log, and the audit row* names only the service, namespace, key, action and revision. Every secret write and every danger change raises an-- * operator alert, again with names and never a value.++ * operator alert, again with names and never a value. A test keeps its outcome as an ai_config_test audit row (the++ * service's one-line answer, which openvibe-shared scrubs of every secret, and a one-way digest of what it tried),++ * so the owner's Setup tab (server/admin/setup.js) can say "Not working" after a reload; the Audit tab redacts it++ * for anyone but the owner, like every config row.** test/ai-config-proxy.test.js*/++const crypto = require('crypto');const express = require('express');const rateLimit = require('express-rate-limit');const contracts = require('openvibe-contracts');@@@ -37,12 +42,20 @@ const { reauthUrl } = require('../auth/reauth');const alerts = require('../operator/alerts');const { isFreshSignIn } = require('../identity/account-merge');--const SERVICES = ['ai', 'billing', 'services', 'watch', 'bench', 'report', 'host', 'codes', 'actor'];++// The nine AI Exchange services (D11), then every other service that holds an owner input (the Setup tab,++// 2026-11-07). A service that has not mounted openvibe-shared/config's routes yet answers 404 or does not answer;++// the panel says it "has not published settings yet".++const SERVICES = ['ai', 'billing', 'services', 'watch', 'bench', 'report', 'host', 'codes', 'actor',++ 'chat', 'connect', 'deals', 'work', 'sources', 'design', 'domains', 'media'];const LABELS = {ai: 'OpenVibe.AI', billing: 'OpenVibe.Billing', services: 'OpenVibe.Services', watch: 'OpenVibe.Watch',bench: 'OpenVibe.Bench', report: 'OpenVibe.Report', host: 'OpenVibe.Host', codes: 'OpenVibe.Codes',-- actor: 'OpenVibe.Actor',++ actor: 'OpenVibe.Actor', chat: 'OpenVibe.Chat', connect: 'OpenVibe Connect', deals: 'OpenVibe.Deals',++ work: 'OpenVibe.Work', sources: 'OpenVibe.Sources', design: 'OpenVibe.Design', domains: 'OpenVibe.Domains',++ media: 'OpenVibe.Media',};++const TEST_TIMEOUT_MS = 20000; // the service gives its tester 15 s; this leaves it time to answer++const TEST_MESSAGE_MAX = 300;const FRESH_S = 10 * 60;const WRITES_PER_HOUR = 60;const KEY_RE = /^[A-Za-z0-9_][A-Za-z0-9_.:-]{0,127}$/;@@@ -110,6 +123,41 @@ function redact(value, secret) {* first 4.110 builds returned and normalised to the contract's array; `kek` and `secrets` are carried beside the* validated summary (the tab's vault row needs the KEK id) but are not part of it.*/++/** The four-character hint, the date and the fingerprint of each secret: what Setup shows ("••••a1b2 · set 3 Nov"). */++function secretMarkers(secrets) {++ if (!secrets || typeof secrets !== 'object' || Array.isArray(secrets)) return {};++ const out = {};++ for (const [key, m] of Object.entries(secrets)) {++ if (!KEY_RE.test(key) || !m || typeof m !== 'object') continue;++ out[key] = {++ hint: typeof m.hint === 'string' && m.hint.length <= 4 ? m.hint : null,++ set_at: typeof m.set_at === 'string' ? m.set_at.slice(0, 40) : null,++ fingerprint: typeof m.fingerprint === 'string' && /^[0-9a-f]{64}$/.test(m.fingerprint) ? m.fingerprint : null,++ bound_to: typeof m.bound_to === 'string' ? m.bound_to.slice(0, 200) : null,++ };++ }++ return out;++}++++/**++ * A one-way digest of the value a test tried: of the secret's marker fingerprint, or of a plain value. A later change++ * to that key gives another digest, so an old failure stops counting; the digest is neither the value nor its marker.++ */++function triedDigest(service, namespace, key, value, marker) {++ const fp = (marker && marker.fingerprint) || (value && typeof value === 'object' && value.redacted === true ? value.fingerprint : null);++ const what = fp ? `secret:${fp}` : `value:${JSON.stringify(value === undefined ? null : value)}`;++ return crypto.createHash('sha256').update(`ov-setup-test|${service}|${namespace}|${key}|${what}`).digest('hex').slice(0, 32);++}++++/** What a namespace answer carries beside the validated summary: env names of pins and the secrets' markers. */++function extrasOf(extra) {++ const out = {};++ if (extra.pin_env) out.pin_env = extra.pin_env;++ const markers = secretMarkers(extra.secrets);++ if (Object.keys(markers).length) out.secret_markers = markers;++ return out;++}++function normaliseSummary(raw) {if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { summary: raw, extra: {} };const summary = { ...raw };@@@ -125,27 +173,13 @@ function normaliseSummary(raw) {return { summary, extra };}--function createAiConfigProxy(opts = {}) {-- const r = express.Router();++/**++ * The one outbound call site of the owner settings (the proxy below and the Setup tab, server/admin/setup.js): a++ * service internal URL from operator config, a fixed /internal or /api path, Network's own token for openvibe.<service>++ * and the owner's usr_ id in X-OV-Subject.++ */++function createServiceCaller(opts = {}) {const log = opts.log || console;-- const now = opts.now || (() => Date.now());-- const writesPerHour = opts.writesPerHour || Number(process.env.AI_CONFIG_WRITES_PER_HOUR) || WRITES_PER_HOUR;---- // Owner only on every route, even if a caller mounts this router without its own guard.-- r.use(requireOwner);---- // 60 writes an hour for the owner: a person cannot accidentally (or maliciously) rewrite the platform's settings-- // faster than that. Reads are not limited; the response says how long to wait.-- r.use(rateLimit({-- windowMs: 60 * 60 * 1000,-- limit: writesPerHour,-- standardHeaders: true,-- legacyHeaders: false,-- skip: (req) => !['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method),-- keyGenerator: (req) => `ai-config:${req.user && req.user.id != null ? req.user.id : req.ip}`,-- handler: (_req, res) => send(res, 429, { ok: false, error: 'Too many settings changes this hour. Try again later.', code: 'config.rate_limited' }),-- }));--const state = (req) => ({db: opts.db || req.app.locals.db,services: opts.services || (req.app.locals.config && req.app.locals.config.services) || {},@@@ -154,7 +188,6 @@ function createAiConfigProxy(opts = {}) {config: opts.config || req.app.locals.config || {},});-- /** The one outbound call site: a service internal URL from operator config, a fixed /internal or /api path. */async function callService(req, service, pathName, { method = 'GET', body = null, cap = `${service}.config.manage`, fresh = false, secret = null, timeout = 8000 } = {}) {const st = state(req);const entry = st.services && st.services[service];@@@ -192,6 +225,62 @@ function createAiConfigProxy(opts = {}) {return { status: upstream.status, answer: secret ? redact(answer, secret) : answer };}++ /**++ * Every namespace of one service, validated as common.config-namespace@1: { answered, namespaces, warnings, vault }.++ * A service that is not reachable, or has no /internal/config yet, is { answered: false, status, detail }.++ */++ async function serviceNamespaces(req, service, { timeout = 8000 } = {}) {++ let out;++ try { out = await callService(req, service, '/internal/config', { timeout }); } catch (err) {++ if (err instanceof ProxyError) return { answered: false, status: err.status, code: err.code, detail: err.message };++ throw err;++ }++ if (out.status < 200 || out.status >= 300) {++ const a = out.answer && typeof out.answer === 'object' ? out.answer : {};++ return { answered: false, status: out.status, code: a.code || 'config.service_refused', detail: a.detail || a.error || `${label(service)} answered ${out.status}` };++ }++ const raw = out.answer && Array.isArray(out.answer.namespaces) ? out.answer.namespaces : [];++ const namespaces = [];++ const warnings = [];++ let vault = null;++ for (const item of raw) {++ const { summary, extra } = normaliseSummary(item);++ const checked = contracts.validate('common.config-namespace@1', summary);++ if (!checked.valid || summary.service !== service) {++ warnings.push({ service, namespace: summary && typeof summary.namespace === 'string' ? summary.namespace : null, reason: 'invalid-summary', detail: checked.valid ? `the summary is for ${summary.service}` : checked.errors.slice(0, 4).map((e) => `${e.path} ${e.message}`).join('; ') });++ continue;++ }++ namespaces.push({ ...summary, ...extrasOf(extra) });++ if (vault === null && typeof summary.sealed === 'boolean') vault = { sealed: summary.sealed, kek: extra.kek !== undefined ? extra.kek : null };++ }++ return { answered: true, namespaces, warnings, vault: vault || { sealed: null, kek: null } };++ }++++ return { state, callService, serviceNamespaces };++}++++function createAiConfigProxy(opts = {}) {++ const r = express.Router();++ const log = opts.log || console;++ const now = opts.now || (() => Date.now());++ const writesPerHour = opts.writesPerHour || Number(process.env.AI_CONFIG_WRITES_PER_HOUR) || WRITES_PER_HOUR;++ const { state, callService, serviceNamespaces } = createServiceCaller(opts);++++ // Owner only on every route, even if a caller mounts this router without its own guard.++ r.use(requireOwner);++++ // 60 writes an hour for the owner: a person cannot accidentally (or maliciously) rewrite the platform's settings++ // faster than that. Reads are not limited; the response says how long to wait.++ r.use(rateLimit({++ windowMs: 60 * 60 * 1000,++ limit: writesPerHour,++ standardHeaders: true,++ legacyHeaders: false,++ skip: (req) => !['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method),++ keyGenerator: (req) => `ai-config:${req.user && req.user.id != null ? req.user.id : req.ip}`,++ handler: (_req, res) => send(res, 429, { ok: false, error: 'Too many settings changes this hour. Try again later.', code: 'config.rate_limited' }),++ }));++function send(res, status, body) {res.set('Cache-Control', 'no-store');return res.status(status).json(body);@@@ -225,6 +314,18 @@ function createAiConfigProxy(opts = {}) {}}++ /** A test's outcome, for the Setup tab: names, the one-line answer and what was tried; never a value. */++ async function recordTest(req, details) {++ const st = state(req);++ if (!st.db || !req.user) return;++ try {++ await st.db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)')++ .run(req.user.id, 'ai_config_test', JSON.stringify(details));++ } catch (err) {++ log.warn(`[AiConfig] test row failed: ${err.message}`);++ }++ }++/*** One operator alert per change, for the owner and OPERATOR_ALERT_USERNAMES: the same notification type Host's* alerts use, so it reaches the bell and web push and no block hides it. It names the service, namespace, key,@@@ -354,23 +455,11 @@ function createAiConfigProxy(opts = {}) {async function listNamespaces(req, res) {try {const service = serviceOf(req);-- const out = await callService(req, service, '/internal/config');-- if (out.status < 200 || out.status >= 300) return upstreamFailed(res, service, out);-- const raw = out.answer && Array.isArray(out.answer.namespaces) ? out.answer.namespaces : [];-- const namespaces = [];-- const warnings = [];-- let vault = null;-- for (const item of raw) {-- const { summary, extra } = normaliseSummary(item);-- const checked = contracts.validate('common.config-namespace@1', summary);-- if (!checked.valid || summary.service !== service) {-- warnings.push({ service, namespace: summary && typeof summary.namespace === 'string' ? summary.namespace : null, reason: 'invalid-summary', detail: checked.valid ? `the summary is for ${summary.service}` : checked.errors.slice(0, 4).map((e) => `${e.path} ${e.message}`).join('; ') });-- continue;-- }-- namespaces.push({ ...summary, ...(extra.pin_env ? { pin_env: extra.pin_env } : {}) });-- if (vault === null && typeof summary.sealed === 'boolean') vault = { sealed: summary.sealed, kek: extra.kek !== undefined ? extra.kek : null };++ const got = await serviceNamespaces(req, service);++ if (!got.answered) {++ return send(res, got.status >= 400 ? got.status : 502, { ok: false, error: got.detail, code: got.code });}-- send(res, 200, { ok: true, service, namespaces, warnings, vault: vault || { sealed: null, kek: null } });++ send(res, 200, { ok: true, service, namespaces: got.namespaces, warnings: got.warnings, vault: got.vault });} catch (err) { fail(res, err); }}// The service called "services" would collide with GET /services (the list of services) if it were asked for as@@@ -393,7 +482,7 @@ function createAiConfigProxy(opts = {}) {vault: { sealed: null, kek: null },});}-- send(res, 200, { ok: true, service, namespace: { ...summary, ...(extra.pin_env ? { pin_env: extra.pin_env } : {}) }, warnings: [], vault: { sealed: summary.sealed, kek: extra.kek !== undefined ? extra.kek : null } });++ send(res, 200, { ok: true, service, namespace: { ...summary, ...extrasOf(extra) }, warnings: [], vault: { sealed: summary.sealed, kek: extra.kek !== undefined ? extra.kek : null } });} catch (err) { fail(res, err); }});@@@ -464,6 +553,38 @@ function createAiConfigProxy(opts = {}) {} catch (err) { fail(res, err); }});++ /**++ * Setup's Test: the service's tester tries the stored value with the provider (openvibe-shared 4.125.0's test hook)++ * and answers { ok, message } in one line. The outcome is kept as an audit row with a digest of what was tried++ * (triedDigest), so a later change to that key makes it stale. No fresh sign-in:++ * a test changes nothing and shows nothing of the value. It counts against the hourly write allowance.++ */++ r.post('/:service/:namespace/test/:key', async (req, res) => {++ try {++ const service = serviceOf(req);++ const namespace = namespaceOf(req, service);++ const key = keyOf(req);++ let tried = null;++ const current = await callService(req, service, `/internal/config/${encodeURIComponent(namespace)}`);++ if (current.status >= 200 && current.status < 300) {++ const { summary, extra } = normaliseSummary(current.answer);++ const value = summary && summary.values ? summary.values[key] : undefined;++ tried = triedDigest(service, namespace, key, value, secretMarkers(extra.secrets)[key]);++ }++ const out = await callService(req, service, `/internal/config/${encodeURIComponent(namespace)}/test/${encodeURIComponent(key)}`, {++ method: 'POST', body: {}, timeout: TEST_TIMEOUT_MS,++ });++ if (out.status < 200 || out.status >= 300) return upstreamFailed(res, service, out);++ const answer = out.answer && typeof out.answer === 'object' ? out.answer : {};++ const passed = answer.ok === true;++ const message = String(answer.message == null ? '' : answer.message).replace(/\s+/g, ' ').trim().slice(0, TEST_MESSAGE_MAX)++ || (passed ? 'It works.' : 'It did not work.');++ const testedAt = new Date(now()).toISOString();++ await recordTest(req, { service, namespace, key, passed, message, tried, tested_at: testedAt });++ send(res, 200, { ok: true, passed, message, tested_at: testedAt });++ } catch (err) { fail(res, err); }++ });++r.put('/:service/:namespace/secrets/:key', async (req, res) => {try {const service = serviceOf(req);@@@ -514,6 +635,6 @@ function createAiConfigProxy(opts = {}) {}module.exports = {-- createAiConfigProxy, SERVICES, FRESH_S, WRITES_PER_HOUR, signInUrl, isFresh, dangerOf, normaliseSummary,-- sanitizeProvider, redact,++ createAiConfigProxy, createServiceCaller, SERVICES, LABELS, label, FRESH_S, WRITES_PER_HOUR, signInUrl, isFresh, dangerOf,++ normaliseSummary, secretMarkers, triedDigest, sanitizeProvider, redact, ProxyError,};@@@ -1037,6 +1037,9 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {// owner (server/admin/ai-config-proxy.js). Owner only on every route; the proxy re-checks, and a write carries the// owner in X-OV-Subject, with a fresh sign-in for keys and x-danger money settings.router.use('/ai/config', requireOwner, require('./ai-config-proxy').createAiConfigProxy({ db, notificationService }));++ // The owner's Setup tab (server/admin/setup.js): every key and account only the owner can give, from every service's++ // config namespaces, plus Network's own to-dos. Owner only; saving goes through the proxy above.++ router.use('/setup', requireOwner, require('./setup').createSetupRoutes({ db }));return router;};nnew file mode 100644@@@ -0,0 +1,299 @@++'use strict';++/**++ * The owner's Setup tab (OWNER-SETUP-AND-ADMIN-CLARITY-DESIGN, 2026-11-07, section 1): one checklist of everything++ * only the owner can give the network — keys, accounts, caps — and the few things to do elsewhere (legal++ * registrations, DNSSEC at the registrar, a provider top-up).++ *++ * GET /api/admin/setup every row, grouped, Needed first, with the counted summary++ * POST /api/admin/setup/tasks/:key { done } marks one of Network's own to-dos done (or not done)++ *++ * Owner only on every route. Setup holds no value: a key row is a field of the owning service's config namespace++ * that carries x-owner-input (openvibe-shared 4.125.0), read through the owner settings proxy's caller++ * (server/admin/ai-config-proxy.js) with Network's token and the owner in X-OV-Subject. Saving, removing and testing++ * a key go through that proxy's routes, with its fresh sign-in rule, vault, audit and alerts. The state of a row:++ * Needed unset, and x-required-for names what waits on it++ * Not working set, and its last test (an ai_config_test audit row for the value set now) failed++ * Connected set, and not failing (tested and passed, or not tested yet); a to-do marked done++ * Optional unset, and nothing waits on it++ * The to-dos are Network's own namespace, network.setup (openvibe-shared/config), so marking one done is a revision++ * with its actor, history and rollback like any other setting.++ *++ * test/setup.test.js++ */++const express = require('express');++const config = require('openvibe-shared/config');++const { requireOwner } = require('../auth/owner-guard');++const siteConfig = require('./site-config');++const { createServiceCaller, SERVICES, label, triedDigest } = require('./ai-config-proxy');++++const NAMESPACE = 'network.setup';++const LIST_TIMEOUT_MS = 4000;++const TESTS_READ = 1000;++const STATES = ['needed', 'not_working', 'connected', 'optional'];++const STATE_RANK = Object.fromEntries(STATES.map((s, i) => [s, i]));++// The groups and their order come with openvibe-shared (config.OWNER_INPUT_GROUPS, 4.125.0). The same list is here++// for a Network still pinned to an older release; a row with a group neither knows lands in "Other".++const GROUPS = config.OWNER_INPUT_GROUPS || Object.freeze({++ money: 'Money', 'ai-providers': 'AI providers', gpus: 'GPUs', voices: 'Voices', 'chat-apps': 'Chat apps',++ 'sign-in-apps': 'Sign-in apps', 'data-sources': 'Data sources', 'design-publishing': 'Design and publishing',++ domains: 'Domains', legal: 'Legal and registrations',++});++const GROUP_IDS = Object.keys(GROUPS);++const KEY_RE = /^[a-z][a-z0-9_]{0,63}$/;++++const DONE = Object.freeze({++ type: ['object', 'null'],++ additionalProperties: false,++ properties: {++ done_at: { type: 'string', maxLength: 40, title: 'Done on', description: 'When the owner marked it done, as an ISO date and time.' },++ done_by: { type: 'string', maxLength: 80, title: 'Done by', description: 'The username of the person who marked it done (the owner).' },++ },++});++const task = (title, description, ownerInput, requiredFor) => ({++ ...DONE, title, description, 'x-owner-input': ownerInput, ...(requiredFor ? { 'x-required-for': requiredFor } : {}),++});++++/**++ * Network's own to-dos: things only the owner can do, somewhere else, that have no key to store. Each value is++ * { done_at, done_by } once marked done.++ */++const TASKS_SCHEMA = Object.freeze({++ title: 'Owner to-dos',++ description: 'Things only the owner can do outside OpenVibe, with no key to keep: registrations, records at the registrar and top-ups. Mark one done when it is.',++ type: 'object',++ additionalProperties: false,++ properties: {++ provider_topup: task('Top up the prepaid AI balances',++ 'Some AI providers run on a prepaid balance. When one runs out, OpenVibe pauses that provider and uses the next one. Mark it done after a top-up.',++ { group: 'money', unlocks: 'Keeps the AI providers that run on a prepaid balance answering.', get_it_at: 'https://ai.openvibe.services/console', get_it_how: 'Open Providers in the AI console, see which balance is low, and top it up on that provider\'s own site.' }),++ dnssec_records: task('Add the DNSSEC records at the registrar',++ 'Signs OpenVibe\'s domains so nobody can answer for them with false addresses. Cloudflare makes one DS record per domain; it works once the registrar has it.',++ { group: 'domains', unlocks: 'Protects every OpenVibe domain from forged DNS answers.', get_it_at: 'https://developers.cloudflare.com/dns/dnssec/', get_it_how: 'Turn on DNSSEC for each domain in Cloudflare (DNS, then Settings), then copy its DS record to the registrar.' }),++ ncmec_registration: task('Register with NCMEC\'s CyberTipline',++ 'Registers OpenVibe as a service that reports child sexual abuse material to NCMEC, which US law asks of any site people upload to. Mark it done once NCMEC confirms.',++ { group: 'legal', unlocks: 'Lets OpenVibe report child abuse imagery to the authorities, as the law requires.', get_it_at: 'https://www.missingkids.org/gethelpnow/cybertipline', get_it_how: 'Follow the link for electronic service providers and ask for an ESP account for OpenVibe.' },++ 'Reporting abuse imagery as US law requires'),++ csam_scanning: task('Turn on CSAM scanning',++ 'Cloudflare compares every image OpenVibe serves with known child abuse imagery and tells you when one matches. It needs the NCMEC registration; nothing changes for people.',++ { group: 'legal', unlocks: 'Finds known child abuse imagery among the pictures OpenVibe serves, so it can be removed and reported.', get_it_at: 'https://developers.cloudflare.com/cache/reference/csam-scanning/', get_it_how: 'In the Cloudflare dashboard open Caching, then Configuration, and turn on the CSAM Scanning Tool with the email NCMEC has.' },++ 'Finding known abuse imagery'),++ dmca_agent: task('Name a DMCA agent',++ 'Registers the person who receives copyright takedown notices with the US Copyright Office. Without it, OpenVibe can lose the protection the law gives it for what people upload.',++ { group: 'legal', unlocks: 'Keeps OpenVibe protected when someone uploads what they do not own.', get_it_at: 'https://dmca.copyright.gov/osp/', get_it_how: 'Make an account, register OpenVibe as a service provider and name the agent. It costs $6 and is renewed every three years.' },++ 'Safe harbour for what people upload'),++ public_suffix_list: task('Add people\'s site domain to the Public Suffix List',++ 'Tells browsers that each subdomain where a person\'s own site lives belongs to someone different, so one person\'s site can never read another\'s cookies.',++ { group: 'legal', unlocks: 'Keeps people\'s own sites from reading each other\'s cookies in every browser.', get_it_at: 'https://github.com/publicsuffix/list/wiki/Guidelines', get_it_how: 'Open a pull request to the list\'s private section and add the _psl TXT record it asks for at the registrar.' }),++ },++});++++const byDb = new WeakMap();++/** Network's own to-do store (openvibe-shared/config, namespace network.setup), one per database handle. */++async function tasksStore(db, log = console) {++ if (byDb.has(db)) return byDb.get(db);++ const ready = config.createConfigStore({++ db, service: 'network', namespace: NAMESPACE, schema: TASKS_SCHEMA,++ log: { info: () => {}, warn: (m) => (log.warn || log.log || (() => {})).call(log, `[Setup] ${m}`), error: (m) => (log.error || log.warn || (() => {})).call(log, `[Setup] ${m}`) },++ });++ byDb.set(db, ready);++ try { return await ready; } catch (err) { byDb.delete(db); throw err; }++}++++const isSet = (value, secret) => {++ if (secret) return !!(value && typeof value === 'object' && value.redacted === true && typeof value.fingerprint === 'string');++ if (value === undefined || value === null || value === '') return false;++ if (Array.isArray(value)) return value.length > 0;++ if (typeof value === 'object') return Object.keys(value).length > 0;++ return true;++};++const groupOf = (g) => (GROUP_IDS.includes(g) ? g : 'other');++const groupLabel = (g) => (g === 'other' ? 'Other' : GROUPS[g]);++const text = (v, max = 500) => (typeof v === 'string' ? v.replace(/\s+/g, ' ').trim().slice(0, max) : '');++const httpsUrl = (v) => { try { const u = new URL(String(v)); return u.protocol === 'https:' ? u.href : null; } catch { return null; } };++const day = (iso) => { const t = Date.parse(iso || ''); return Number.isFinite(t) ? new Date(t).toISOString().slice(0, 10) : null; };++++/** The latest test of each key (an ai_config_test audit row), newest first wins. */++async function latestTests(db) {++ const out = new Map();++ let rows = [];++ try {++ rows = await db.prepare("SELECT details, created_at FROM audit_log WHERE action = 'ai_config_test' ORDER BY id DESC LIMIT ?").all(TESTS_READ);++ } catch { rows = []; }++ for (const r of rows) {++ let d;++ try { d = JSON.parse(r.details); } catch { continue; }++ if (!d || typeof d !== 'object') continue;++ const id = `${d.service}|${d.namespace}|${d.key}`;++ if (out.has(id)) continue;++ out.set(id, {++ passed: d.passed === true, message: text(d.message, 300), at: text(d.tested_at, 40) || (r.created_at ? new Date(r.created_at).toISOString() : null),++ tried: typeof d.tried === 'string' ? d.tried : null,++ });++ }++ return out;++}++++/**++ * Whether a key is a secret. The summary classifies every schema property from openvibe-shared 4.125.0; a service on++ * an older release leaves an unset key out, and then a bound key, a writeOnly one or a key/token/secret/password name++ * is taken for a secret, so its value goes to the write-only route rather than a plain field.++ */++function isSecretKey(ns, key, prop) {++ const cls = ns.classification ? ns.classification[key] : undefined;++ if (cls) return cls === 'secret';++ return typeof prop['x-bind'] === 'string' || prop.writeOnly === true || /(^|[._-])(api_?key|key|token|secret|password)$/i.test(key);++}++++/** One key row from a field with x-owner-input. */++function keyRow(service, ns, key, prop, tests) {++ const owner = prop['x-owner-input'];++ const secret = isSecretKey(ns, key, prop);++ const pinned = Array.isArray(ns.pins) && ns.pins.includes(key);++ const pinEnv = pinned && ns.pin_env ? ns.pin_env[key] || null : null;++ const value = ns.values ? ns.values[key] : undefined;++ const marker = ns.secret_markers ? ns.secret_markers[key] : null;++ const set = isSet(value, secret);++ const types = [].concat(prop.type || 'string');++ const type = types.find((t) => t !== 'null') || 'string';++ let test = tests.get(`${service}|${ns.namespace}|${key}`) || null;++ // A test is about the value it tried: once that key holds another value, its old answer no longer counts.++ if (test && set && test.tried && test.tried !== triedDigest(service, ns.namespace, key, value, marker)) test = null;++ if (!set) test = null;++ const requiredFor = text(prop['x-required-for'], 200) || null;++ let state;++ if (!set) state = requiredFor ? 'needed' : 'optional';++ else if (test && !test.passed) state = 'not_working';++ else state = 'connected';++ return {++ id: `${service}/${ns.namespace}/${key}`, kind: 'key', service, service_name: label(service), namespace: ns.namespace, key,++ group: groupOf(owner.group), title: text(prop.title, 120) || key, description: text(prop.description, 600),++ unlocks: text(owner.unlocks, 300), get_it_at: httpsUrl(owner.get_it_at), get_it_how: text(owner.get_it_how, 300) || null,++ required_for: requiredFor, testable: owner.test === true, secret, type, unit: text(prop['x-unit'], 20) || null,++ example: secret ? null : (['string', 'number', 'boolean'].includes(typeof prop['x-example']) ? prop['x-example'] : null),++ danger: prop['x-danger'] === true, pinned, pin_env: pinEnv,++ set, hint: marker && marker.hint ? marker.hint : null, set_at: day(marker && marker.set_at),++ value: !secret && set && ['string', 'number', 'boolean'].includes(typeof value) ? value : null,++ state, last_test: test ? { passed: test.passed, message: test.message, at: test.at } : null,++ };++}++++/** One of Network's own to-dos. */++function taskRow(key, prop, value) {++ const owner = prop['x-owner-input'];++ const done = value && typeof value === 'object' && value.done_at ? { at: day(value.done_at), by: text(value.done_by, 80) || null } : null;++ const requiredFor = text(prop['x-required-for'], 200) || null;++ return {++ id: `network/${NAMESPACE}/${key}`, kind: 'task', service: 'network', service_name: 'OpenVibe.Network', namespace: NAMESPACE, key,++ group: groupOf(owner.group), title: prop.title, description: prop.description, unlocks: owner.unlocks,++ get_it_at: httpsUrl(owner.get_it_at), get_it_how: owner.get_it_how || null, required_for: requiredFor,++ testable: false, secret: false, set: !!done, done, state: done ? 'connected' : requiredFor ? 'needed' : 'optional', last_test: null,++ };++}++++const byState = (a, b) => STATE_RANK[a.state] - STATE_RANK[b.state]++ || (GROUP_IDS.indexOf(a.group) + 1 || 99) - (GROUP_IDS.indexOf(b.group) + 1 || 99)++ || a.service_name.localeCompare(b.service_name) || a.title.localeCompare(b.title);++++/**++ * The checklist: rows sorted Needed, Not working, Connected, Optional; groups ordered by their most urgent row++ * (so the page opens on what needs the owner), then by the design's order. The counts add up to the rows.++ */++function checklist(rows) {++ const sorted = [...rows].sort(byState);++ const counts = { needed: 0, not_working: 0, connected: 0, optional: 0, total: rows.length };++ for (const r of rows) counts[r.state] += 1;++ const groups = new Map();++ for (const r of sorted) {++ if (!groups.has(r.group)) groups.set(r.group, { id: r.group, label: groupLabel(r.group), rows: [], counts: { needed: 0, not_working: 0, connected: 0, optional: 0 } });++ const g = groups.get(r.group);++ g.rows.push(r);++ g.counts[r.state] += 1;++ }++ const rank = (g) => Math.min(...g.rows.map((r) => STATE_RANK[r.state]));++ const order = (id) => (GROUP_IDS.indexOf(id) + 1) || 99;++ const list = [...groups.values()].sort((a, b) => rank(a) - rank(b) || order(a.id) - order(b.id));++ const empty = GROUP_IDS.filter((id) => !groups.has(id)).map((id) => ({ id, label: GROUPS[id] }));++ return { rows: sorted, groups: list, empty_groups: empty, counts };++}++++function createSetupRoutes(opts = {}) {++ const r = express.Router();++ const log = opts.log || console;++ const now = opts.now || (() => Date.now());++ const caller = createServiceCaller(opts);++ r.use(requireOwner);++++ const send = (res, status, body) => { res.set('Cache-Control', 'no-store'); return res.status(status).json(body); };++ const dbOf = (req) => opts.db || req.app.locals.db;++++ r.get('/', async (req, res) => {++ try {++ const db = dbOf(req);++ const services = opts.services || SERVICES;++ const [answers, tests, store] = await Promise.all([++ Promise.all(services.map(async (service) => {++ try { return { service, ...(await caller.serviceNamespaces(req, service, { timeout: LIST_TIMEOUT_MS })) }; } catch (err) {++ log.warn(`[Setup] ${service}: ${err && err.message ? String(err.message).split('\n')[0].slice(0, 160) : 'failed'}`);++ return { service, answered: false, detail: `${label(service)} did not answer` };++ }++ })),++ latestTests(db),++ tasksStore(db, log),++ ]);++ const rows = [];++ const reach = [];++ for (const a of answers) {++ let count = 0;++ for (const ns of a.answered ? a.namespaces : []) {++ const props = ns.schema && ns.schema.properties && typeof ns.schema.properties === 'object' ? ns.schema.properties : {};++ for (const [key, prop] of Object.entries(props)) {++ if (!prop || typeof prop !== 'object' || !prop['x-owner-input'] || typeof prop['x-owner-input'] !== 'object') continue;++ rows.push(keyRow(a.service, ns, key, prop, tests));++ count += 1;++ }++ }++ reach.push({++ service: a.service, name: label(a.service), answered: !!a.answered,++ namespaces: a.answered ? a.namespaces.length : 0, rows: count,++ // In plain words, as the AI tab says it: a service that has no settings to show yet.++ note: !a.answered || !a.namespaces.length ? `${label(a.service)} has not published settings yet.` : (count ? null : `${label(a.service)} asks nothing of you yet.`),++ });++ }++ const values = store.get();++ for (const [key, prop] of Object.entries(TASKS_SCHEMA.properties)) rows.push(taskRow(key, prop, values[key]));++ send(res, 200, { ok: true, generated_at: new Date(now()).toISOString(), ...checklist(rows), services: reach });++ } catch (err) {++ log.error(`[Setup] ${err && err.message ? String(err.message).split('\n')[0].slice(0, 200) : 'failed'}`);++ send(res, 500, { ok: false, error: 'Setup could not be read', code: 'setup.internal' });++ }++ });++++ r.post('/tasks/:key', async (req, res) => {++ try {++ const key = String(req.params.key || '');++ if (!KEY_RE.test(key) || !Object.prototype.hasOwnProperty.call(TASKS_SCHEMA.properties, key)) return send(res, 404, { ok: false, error: 'No such to-do', code: 'setup.task_not_found' });++ const body = req.body && typeof req.body === 'object' ? req.body : {};++ if (typeof body.done !== 'boolean') return send(res, 400, { ok: false, error: 'Say done: true or false', code: 'setup.bad_request' });++ const db = dbOf(req);++ const store = await tasksStore(db, log);++ const actor = siteConfig.actorOf(req.user);++ const title = TASKS_SCHEMA.properties[key].title;++ if (!body.done && store.get(key) == null) return send(res, 200, { ok: true, row: taskRow(key, TASKS_SCHEMA.properties[key], null) });++ const snap = body.done++ ? await store.apply({ [key]: { done_at: new Date(now()).toISOString(), done_by: String(req.user.username || '').slice(0, 80) } }, { merge: true, actor, reason: `Marked done: ${title}` })++ : await store.apply({}, { merge: true, unset: [key], actor, reason: `Marked not done: ${title}` });++ try {++ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)')++ .run(req.user.id, 'setup_task', JSON.stringify({ key, done: body.done, revision: snap && snap.revision }));++ } catch (err) { log.warn(`[Setup] audit row failed: ${err.message}`); }++ send(res, 200, { ok: true, row: taskRow(key, TASKS_SCHEMA.properties[key], store.get(key)) });++ } catch (err) {++ log.error(`[Setup] ${err && err.message ? String(err.message).split('\n')[0].slice(0, 200) : 'failed'}`);++ send(res, 500, { ok: false, error: 'The to-do could not be saved', code: 'setup.internal' });++ }++ });++++ return r;++}++++module.exports = { createSetupRoutes, checklist, keyRow, taskRow, isSecretKey, tasksStore, latestTests, TASKS_SCHEMA, NAMESPACE, GROUPS, STATES };@@@ -183,5 +183,25 @@ module.exports = {actor: {internalUrl: process.env.OV_ACTOR_INTERNAL_URL || 'http://127.0.0.1:4950',},++ // The owner's Setup tab (server/admin/setup.js) reaches every service that holds an owner input. Chat and++ // Media are above; these are the rest.++ connect: {++ internalUrl: process.env.OV_CONNECT_INTERNAL_URL || 'http://127.0.0.1:5240',++ },++ deals: {++ internalUrl: process.env.OV_DEALS_INTERNAL_URL || 'http://127.0.0.1:4840',++ },++ work: {++ internalUrl: process.env.OV_WORK_INTERNAL_URL || 'http://127.0.0.1:4960',++ },++ sources: {++ internalUrl: process.env.OV_SOURCES_INTERNAL_URL || 'http://127.0.0.1:4720',++ },++ design: {++ internalUrl: process.env.OV_DESIGN_INTERNAL_URL || 'http://127.0.0.1:5090',++ },++ domains: {++ internalUrl: process.env.OV_DOMAINS_INTERNAL_URL || 'http://127.0.0.1:5070',++ },},};@@@ -336,6 +336,14 @@ const DEFAULT_GRANTS = [// CHANGED_DEFAULT_NAMESPACES entry is needed. The deploy applies them from ensureSchema() at boot....['ai', 'billing', 'services', 'watch', 'bench', 'report', 'host', 'codes', 'actor'].map((svc) => ['network', `${svc}.config.manage`, `openvibe.${svc}`, []]),++ // The owner's Setup tab (OWNER-SETUP-AND-ADMIN-CLARITY-DESIGN, 2026-11-07): the same proxy reaches the eight more++ // services that hold an owner input. Their <svc>.config.manage capabilities are Contracts 0.308.0 (planned until++ // each service mounts /internal/config). A grant is seeded only once the pinned Contracts knows its capability, so++ // this works on the pin before 0.308.0 and seeds all eight at the first boot after the pin moves. New rows, as for++ // the nine: no RESTORED_GRANTS or CHANGED_DEFAULT_NAMESPACES entry.++ ...['chat', 'connect', 'deals', 'work', 'sources', 'design', 'domains', 'media']++ .filter((svc) => capabilities.get(`${svc}.config.manage`))++ .map((svc) => ['network', `${svc}.config.manage`, `openvibe.${svc}`, []]),// The owner's AI tab lists and edits AI's provider registry through AI's own admin API (GET/POST/PATCH// /api/v1/providers), as Network, naming the owner in X-OV-Subject.['network', 'ai.provider.manage', 'openvibe.ai', []],nnew file mode 100644@@@ -0,0 +1,86 @@++'use strict';++// The AI tab in plain words (public/js/admin/ai.js; OWNER-SETUP-AND-ADMIN-CLARITY-DESIGN, 2026-11-07, sections 2++// and 3): the table's words for billing, access, keys and health; a title with the machine key small and grey after++// it (a key without a title reads as words); every choice from x-enum-labels; x-advanced fields and raw JSON in a++// counted "Advanced settings (N)" fold; an object with described properties as a small form; an unset secret offers++// the write-only route.++// node test/admin-ai-clarity.test.js++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const vm = require('vm');++++const src = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'admin', 'ai.js'), 'utf8');++const ctx = {++ esc: (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''),++ timeAgo: () => '2h ago', currentUser: null, console,++};++vm.createContext(ctx);++vm.runInContext(src + '\n;globalThis.__pai = { paiSetupOnly, paiField, paiCard, paiKeyState, paiHealth, paiHumanize, PAI_BILLING_LABEL, PAI_ACCESS_LABEL, paiProviderRow };', ctx);++const P = ctx.__pai;++++// ── The table's words, verbatim ──++// A namespace made only of non-AI owner inputs (Codes' publishing tokens) is the Setup tab's, not "AI settings".++{++ const oi = (group) => ({ type: 'string', 'x-owner-input': { group, unlocks: 'x' } });++ assert.strictEqual(P.paiSetupOnly({ schema: { properties: { npm_token: oi('design-publishing'), chrome: oi('design-publishing') } } }), true);++ assert.strictEqual(P.paiSetupOnly({ schema: { properties: { key: oi('ai-providers'), npm_token: oi('design-publishing') } } }), false, 'one AI input keeps it on the AI tab');++ assert.strictEqual(P.paiSetupOnly({ schema: { properties: { model: { type: 'string' } } } }), false, 'a plain AI setting stays');++ assert.strictEqual(P.paiSetupOnly({ schema: { properties: {} } }), false);++}++assert.deepStrictEqual({ ...P.PAI_BILLING_LABEL }, {++ metered: 'Spends Credits per request', subscription: 'A plan with a monthly fee shared by everyone',++ payg: 'A prepaid balance at the provider', free: 'Free', byok: 'The person\'s own key pays',++});++assert.deepStrictEqual({ ...P.PAI_ACCESS_LABEL }, {++ OWN_DEPLOYMENT: 'Runs on our own machine: no provider terms', AUTHORIZED_WHOLESALE: 'Our agreement allows reselling it',++ CUSTOMER_BYOK: 'Only with the person\'s own key', RESTRICTED: 'Do not sell: their terms forbid it',++ UNVERIFIED: 'Not checked yet: treated as Do not sell',++});++assert.match(P.paiKeyState({ secret_ref: 'vault:deepseek', credentials: 'configured', auth_mode: 'bearer', base_url: 'https://api.deepseek.com' }).text, /^Key: Entered here, locked/);++assert.strictEqual(P.paiKeyState({ secret_ref: 'env:DEEPSEEK_API_KEY', credentials: 'configured', auth_mode: 'bearer' }).text, 'Key: From the server\'s settings file (DEEPSEEK_API_KEY)');++assert.strictEqual(P.paiKeyState({ secret_ref: null, credentials: 'missing', auth_mode: 'bearer' }).text, 'Key: Not set');++assert.strictEqual(P.paiHealth({ health: { state: 'closed' } }).text, 'Health: Working');++assert.strictEqual(P.paiHealth({ health: { state: 'half_open' } }).text, 'Health: Testing again');++assert.match(P.paiHealth({ health: { state: 'open', consecutive_failures: 5, opened_at: Date.UTC(2026, 10, 7, 14, 4) } }).text, /^Health: Paused after 5 errors; tries again at \d{1,2}[:.]\d{2}/);++const row = P.paiProviderRow({ key: 'openrouter', display_name: 'OpenRouter', base_url: 'https://openrouter.ai', secret_ref: 'env:X', credentials: 'configured', auth_mode: 'bearer', billing_profile: 'metered', provider_access: 'UNVERIFIED', status: 'active', health: { state: 'closed' } });++assert.ok(!/circuit|half.open|Sealed|vault|BYOK|Metered/i.test(row.replace(/data-act="[^"]*"/g, '')), 'no internal word is shown on a provider row');++++// ── Titles, keys, choices ──++const n = {++ service: 'ai', namespace: 'ai.exchange', revision: 3, pins: [],++ values: { min_samples: 5, routing: 'auto', limits: { per_minute: 20 }, raw: { a: 1 }, alpha: 0.2 },++ classification: { min_samples: 'internal', routing: 'internal', limits: 'internal', raw: 'internal', alpha: 'internal' },++ schema: {++ title: 'What the Exchange offers', type: 'object',++ properties: {++ min_samples: { type: 'integer', description: 'Below this many attempts, evidence is reported and never trusted.' },++ routing: { type: 'string', title: 'How a model is chosen', enum: ['auto', 'pinned'], 'x-enum-labels': { auto: { label: 'By what works best for the money', description: 'Measured cost per success decides' } } },++ limits: { type: 'object', title: 'Per person limits', properties: { per_minute: { type: 'integer', title: 'Requests a minute' } } },++ raw: { type: 'object', title: 'Route overrides' },++ alpha: { type: 'number', title: 'How fast new measurements count', 'x-advanced': true },++ provider_key: { type: 'string', title: 'Provider key' },++ },++ },++};++const f = P.paiField(n, 'min_samples', n.schema.properties.min_samples, false, null);++assert.match(f, /<label>Min samples <code>min_samples<\/code><\/label>/, 'a key without a title reads as words, the key small after it');++const choice = P.paiField(n, 'routing', n.schema.properties.routing, false, null);++assert.match(choice, /<option value="auto" selected data-desc="Measured cost per success decides">By what works best for the money<\/option>/, 'an option in words with its line');++assert.match(choice, /<option value="pinned">Pinned<\/option>/, 'an option without a label still reads as a word');++const form = P.paiField(n, 'limits', n.schema.properties.limits, false, null);++assert.match(form, /data-pai-object/, 'an object whose properties are described is a small form');++assert.ok(!/<textarea/.test(form), 'not raw JSON');++const secret = P.paiField(n, 'provider_key', n.schema.properties.provider_key, false, null);++assert.match(secret, /Not set/);++assert.match(secret, /data-act="secret"[^>]*>Enter it</, 'an unset key goes to the write-only route, not a plain field');++const card = P.paiCard(n);++assert.match(card, /<h5>What the Exchange offers <code class="pai-mkey">ai\.exchange<\/code><\/h5>/, 'the card title, with the namespace small after it');++assert.match(card, /<summary>Advanced settings \(2\)<\/summary>/, 'x-advanced and raw JSON fold under a counted Advanced settings');++const [mainPart, advancedPart] = card.split('<details class="pai-advanced">');++assert.ok(!/<textarea/.test(mainPart), 'raw JSON is only under Advanced settings');++assert.match(advancedPart, /data-key="raw"/);++assert.match(advancedPart, /data-key="alpha"/);++assert.match(card, /Saved version 3/);++assert.strictEqual(P.paiHumanize('vast.key'), 'Vast key');++console.log('admin-ai-clarity: the table\'s words, titles, choices, folds and forms render as designed');@@@ -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', 'payments.js', 'ai.js', 'content.js', 'analytics.js', 'deploy.js', 'boot.js'];++const modules = ['core.js', 'dashboard.js', 'users.js', 'moderation.js', 'settings.js', 'payments.js', 'glossary.js', 'ai.js', 'setup.js', 'content.js', 'analytics.js', 'deploy.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,60 @@++'use strict';++// The Setup tab's words and the glossary in the browser scripts (public/js/admin/setup.js, public/js/admin/glossary.js;++// OWNER-SETUP-AND-ADMIN-CLARITY-DESIGN, 2026-11-07): the counted summary, each state as a sentence, the glossary's++// words with their "What does this mean?" links, and the owner-only tab in public/js/admin/core.js.++// node test/admin-setup-ui.test.js++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const vm = require('vm');++++const dir = path.join(__dirname, '..', 'public', 'js', 'admin');++const read = (f) => fs.readFileSync(path.join(dir, f), 'utf8');++const ctx = {++ esc: (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''),++ timeAgo: () => '5m ago', currentUser: null, console,++ document: { addEventListener() {} }, location: { hash: '' },++};++vm.createContext(ctx);++// glossary.js, ai.js (paiUsd) and setup.js in their load order, as admin.html loads them.++vm.runInContext(read('glossary.js') + '\n' + read('ai.js') + '\n' + read('setup.js')++ + '\n;globalThis.__s = { setupSummary, setupStateLine, setupFor, glossaryLink, GLOSSARY };', ctx);++const S = ctx.__s;++++// ── The counted summary, in the design's words ──++assert.strictEqual(S.setupSummary({ needed: 6, not_working: 2, connected: 31, optional: 0 }), '6 things need you · 2 not working · 31 connected');++assert.strictEqual(S.setupSummary({ needed: 1, not_working: 0, connected: 4, optional: 3 }), '1 thing needs you · 0 not working · 4 connected · 3 optional');++++// ── Each state is a sentence ──++const key = { kind: 'key', secret: true, testable: true };++assert.strictEqual(S.setupStateLine({ ...key, state: 'needed', required_for: 'Card payments' }), 'Needed for card payments.');++assert.strictEqual(S.setupStateLine({ ...key, state: 'needed', required_for: 'GPUs for rent' }), 'Needed for GPUs for rent.', 'an acronym keeps its capitals');++assert.strictEqual(S.setupStateLine({ ...key, state: 'optional' }), 'Not set. Nothing waits on it.');++assert.strictEqual(S.setupStateLine({ ...key, state: 'not_working', set: true, hint: 'WXYZ', set_at: '2026-11-07', last_test: { passed: false, message: 'Telegram answered 401', at: 'x' } }),++ 'Entered here, locked · ••••WXYZ · set 2026-11-07; the last test failed 5m ago: Telegram answered 401.');++assert.strictEqual(S.setupStateLine({ ...key, state: 'connected', set: true, hint: 'a1b2', last_test: null }), 'Entered here, locked · ••••a1b2; not tested yet.');++assert.strictEqual(S.setupStateLine({ kind: 'key', state: 'connected', set: true, pinned: true, pin_env: 'NPM_TOKEN', secret: true, last_test: null }), 'From the server\'s settings file (NPM_TOKEN).');++assert.strictEqual(S.setupStateLine({ kind: 'key', state: 'connected', set: true, value: false, type: 'boolean' }), 'Off.', 'a switch says on or off, not true or false');++assert.strictEqual(S.setupStateLine({ kind: 'key', state: 'connected', set: true, value: 2500000000, unit: 'nano_usd' }), 'Set to $2.5.');++assert.strictEqual(S.setupStateLine({ kind: 'task', state: 'connected', done: { at: '2026-11-07', by: 'goosely' } }), 'Done on 2026-11-07 by @goosely.');++assert.strictEqual(S.setupStateLine({ kind: 'task', state: 'needed', required_for: 'Safe harbour for what people upload' }), 'Needed for safe harbour for what people upload.');++assert.ok(!/undefined|null|true|false/.test(S.setupStateLine({ ...key, state: 'connected', set: true, last_test: { passed: true, at: 'x', message: 'ok' } })));++++// ── The glossary: the words the design names, each with a line, and the link next to a word ──++const ids = S.GLOSSARY.map((g) => g.id);++for (const id of ['credits', 'byok', 'resale', 'cache', 'quota', 'workflow']) assert.ok(ids.includes(id), `the glossary explains ${id}`);++// 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('nope'), '', 'no link for a word the glossary does not have');++++// ── Setup is an owner-only tab (public/js/admin/core.js) ──++const core = read('core.js');++assert.match(core.match(/const OWNER_ONLY_TABS = new Set\(\[[^\]]*\]\)/)[0], /'setup'/);++assert.ok(!/OWNER_ONLY_TABS = new Set\(\[[^\]]*'glossary'/.test(core), 'the glossary is for every admin');++// A key's input is built as a DOM node, never in an HTML string, and is cleared after a save.++const setup = read('setup.js');++assert.ok(!/<input[^>]*type="password"/.test(setup), 'no password input in an HTML string');++assert.match(setup, /if \(isSecret\) input\.value = '';/, 'the key is cleared whatever happened');++console.log('admin-setup-ui: the summary, the states, the glossary and the owner-only tab read as designed');@@@ -4,6 +4,8 @@// and (when fresh) X-OV-Fresh; a secret write or an x-danger change needs a sign-in within 10 minutes; an invalid// summary is dropped with a warning; every secret and danger change is audited and raises an operator alert; and no// value ever reaches a Network table or log line.++// The Setup tab (2026-11-07) adds: seventeen services, each secret's marker (hint and date) forwarded, and the test++// route, whose outcome is kept as an ai_config_test audit row and never carries a value.// node test/ai-config-proxy.test.jsconst assert = require('assert');const http = require('http');@@@ -81,7 +83,7 @@ function summary(service, namespace, extra = {}) {const ownerCall = (method, p, body) => call({ authorization: `Bearer ${fresh}`, cookie: `ov_token=${fresh}` }, method, p, body);// ── The answer the fakes give, per namespace ───────────────────-- const exchange = summary('ai', 'ai.exchange', { kek: 'k1' });++ const exchange = summary('ai', 'ai.exchange', { kek: 'k1', secrets: { provider_key: { redacted: true, fingerprint: FP, hint: 'a1b2', set_at: NOW, set_by: { type: 'user', id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ' }, bound_to: 'https://api.deepseek.com', value: SECRET } } });const broken = summary('ai', 'ai.broken', { values: { api_key: { redacted: true, fingerprint: FP, hint: 'a1b2', value: SECRET } } });const credits = summary('billing', 'billing.credits', { sealed: false });const creditsBroken = summary('billing', 'billing.broken', { values: { api_key: { redacted: true, fingerprint: FP, value: 'a-plain-value' } } });@@@ -93,6 +95,7 @@ function summary(service, namespace, extra = {}) {'POST /internal/config/ai.exchange/rollback': { service: 'ai', namespace: 'ai.exchange', revision: 9, state: 'active' },'PUT /internal/config/ai.exchange/secrets/provider_key': { redacted: true, fingerprint: FP, hint: 'a1b2', set_at: NOW, bound_to: 'https://api.deepseek.com' },'DELETE /internal/config/ai.exchange/secrets/provider_key': { redacted: true, fingerprint: FP, hint: 'a1b2', set_at: NOW, bound_to: null },++ 'POST /internal/config/ai.exchange/test/provider_key': { ok: false, message: '401 Unauthorized:\n the key was refused' },'PUT /internal/config/ai.provider_keys/secrets/deepseek': { redacted: true, fingerprint: FP, hint: 'a1b2', set_at: NOW, bound_to: 'https://api.deepseek.com' },'DELETE /internal/config/ai.provider_keys/secrets/deepseek': { redacted: true, fingerprint: FP, hint: 'a1b2', set_at: NOW, bound_to: null },'GET /api/v1/providers': { providers: [{ key: 'deepseek', display_name: 'DeepSeek', kind: 'openai', status: 'active', base_url: 'https://api.deepseek.com', secret_ref: 'vault:deepseek', billing_profile: 'metered', metadata: { provider_access: 'OWN_DEPLOYMENT' }, credentials: 'configured' }] },@@@ -111,10 +114,12 @@ function summary(service, namespace, extra = {}) {assert.strictEqual(r.status, 403, 'a plain admin never gets the owner settings');assert.strictEqual(configCalls().length, 0, 'a refused request never reaches a service');-- // ── GET /services: the nine services and their operator-set internal URLs ──++ // ── GET /services: the seventeen services and their operator-set internal URLs ──r = await ownerCall('GET', '/api/admin/ai/config/services');assert.strictEqual(r.status, 200, r.text);-- assert.strictEqual(r.body.services.length, 9);++ assert.deepStrictEqual(r.body.services.map((s) => s.service), ['ai', 'billing', 'services', 'watch', 'bench', 'report', 'host', 'codes', 'actor', 'chat', 'connect', 'deals', 'work', 'sources', 'design', 'domains', 'media']);++ assert.strictEqual(r.body.services.find((s) => s.service === 'deals').name, 'OpenVibe.Deals');++ assert.strictEqual(r.body.services.find((s) => s.service === 'deals').capability, 'deals.config.manage');assert.strictEqual(r.body.services.find((s) => s.service === 'ai').internal_url, ai.url);assert.strictEqual(r.body.services.find((s) => s.service === 'billing').internal_url, billing.url);@@@ -130,6 +135,8 @@ function summary(service, namespace, extra = {}) {assert.match(r.body.warnings[0].detail, /api_key|redacted|additional/i);assert.deepStrictEqual(r.body.vault, { sealed: true, kek: 'k1' });assert.ok(!r.text.includes(SECRET), 'the dropped summary never reaches the browser');++ assert.deepStrictEqual(r.body.namespaces[0].secret_markers, { provider_key: { hint: 'a1b2', set_at: NOW, fingerprint: FP, bound_to: 'https://api.deepseek.com' } },++ 'a secret\'s hint and date reach the panel, and nothing else of its marker');const listCall = ai.seen.find((x) => x.method === 'GET' && x.url === '/internal/config');assert.match(listCall.headers.authorization, /^Bearer /, 'the service gets Network\'s own token');assert.strictEqual(listCall.headers['x-ov-subject'], owner.subject, 'the owner travels in X-OV-Subject');@@@ -185,6 +192,24 @@ function summary(service, namespace, extra = {}) {assert.deepStrictEqual({ service: secretAudit.service, namespace: secretAudit.namespace, key: secretAudit.key, action: secretAudit.action }, { service: 'ai', namespace: 'ai.exchange', key: 'provider_key', action: 'secret.put' });assert.ok(!JSON.stringify(secretAudit).includes(SECRET));++ // ── Test: the service's one-line answer, kept as an audit row with the fingerprint it tried ──++ r = await call('staff', 'POST', '/api/admin/ai/config/ai/ai.exchange/test/provider_key', {});++ assert.strictEqual(r.status, 403, 'a plain admin cannot run a test');++ r = await call({ authorization: `Bearer ${stale}`, cookie: `ov_token=${stale}` }, 'POST', '/api/admin/ai/config/ai/ai.exchange/test/provider_key', {});++ assert.strictEqual(r.status, 200, 'a test needs no fresh sign-in: it changes nothing and shows nothing of the value');++ assert.deepStrictEqual({ ok: r.body.ok, passed: r.body.passed, message: r.body.message }, { ok: true, passed: false, message: '401 Unauthorized: the key was refused' }, 'one line');++ const testCall = ai.seen.filter((x) => x.method === 'POST' && x.url === '/internal/config/ai.exchange/test/provider_key').at(-1);++ assert.strictEqual(testCall.headers['x-ov-subject'], owner.subject);++ assert.match(testCall.headers.authorization, /^Bearer /);++ const testRow = JSON.parse((await w.db.prepare("SELECT details FROM audit_log WHERE action = 'ai_config_test' ORDER BY id DESC LIMIT 1").get()).details);++ assert.deepStrictEqual({ service: testRow.service, namespace: testRow.namespace, key: testRow.key, passed: testRow.passed, message: testRow.message },++ { service: 'ai', namespace: 'ai.exchange', key: 'provider_key', passed: false, message: '401 Unauthorized: the key was refused' }, 'the test row names the key and keeps the answer');++ const { triedDigest } = require('../server/admin/ai-config-proxy');++ assert.strictEqual(testRow.tried, triedDigest('ai', 'ai.exchange', 'provider_key', undefined, { fingerprint: FP }), 'it keeps a digest of what it tried');++ assert.ok(!JSON.stringify(testRow).includes(FP), 'never the marker\'s fingerprint itself');++ r = await ownerCall('POST', '/api/admin/ai/config/ai/ai.exchange/test/nothing_here', {});++ assert.strictEqual(r.status, 404, 'a key the service does not test relays the service\'s answer');++// ── Secret delete, rollback, and the provider registry (GET/POST/PATCH and its write-only key) ──r = await ownerCall('DELETE', '/api/admin/ai/config/ai/ai.exchange/secrets/provider_key', { reason: 'gone' });assert.strictEqual(r.status, 200, r.text);@@@ -219,6 +244,17 @@ function summary(service, namespace, extra = {}) {for (const svc of ['ai', 'billing', 'services', 'watch', 'bench', 'report', 'host', 'codes', 'actor']) {assert.ok(has('network', `${svc}.config.manage`, `openvibe.${svc}`), `Network holds ${svc}.config.manage`);}++ // The eight Setup services: Network holds each grant as soon as the pinned Contracts (0.308.0) knows it.++ const { capabilities, version: contractsVersion } = require('openvibe-contracts');++ const setupServices = ['chat', 'connect', 'deals', 'work', 'sources', 'design', 'domains', 'media'];++ for (const svc of setupServices) {++ const known = !!capabilities.get(`${svc}.config.manage`);++ assert.strictEqual(has('network', `${svc}.config.manage`, `openvibe.${svc}`), known, `Network holds ${svc}.config.manage exactly when Contracts knows it`);++ }++ const [maj, min] = String(contractsVersion || require('openvibe-contracts/package.json').version).split('.').map(Number);++ if (maj > 0 || min >= 308) {++ for (const svc of setupServices) assert.ok(has('network', `${svc}.config.manage`, `openvibe.${svc}`), `from Contracts 0.308.0 Network holds ${svc}.config.manage`);++ }assert.ok(has('network', 'ai.provider.manage', 'openvibe.ai'), 'Network holds ai.provider.manage');assert.ok(has('host', 'ai.usage.read', 'openvibe.ai'), 'Host holds ai.usage.read');// The boot seed only inserts a grant when its client exists. In production the network principal does; here we@@@ -226,7 +262,9 @@ function summary(service, namespace, extra = {}) {await w.db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES ('network', 'x', 'OpenVibe.Network (service)', '[]', 1) ON CONFLICT DO NOTHING").run();await principals.ensureSchema(w.db);const seeded = (await w.db.prepare("SELECT capability, audience FROM principal_grants WHERE client_id = 'network' AND capability LIKE '%config.manage' AND revoked_at IS NULL ORDER BY capability").all()).map((x) => `${x.capability}|${x.audience}`).sort();-- assert.deepStrictEqual(seeded, ['actor.config.manage|openvibe.actor', 'ai.config.manage|openvibe.ai', 'bench.config.manage|openvibe.bench', 'billing.config.manage|openvibe.billing', 'codes.config.manage|openvibe.codes', 'host.config.manage|openvibe.host', 'report.config.manage|openvibe.report', 'services.config.manage|openvibe.services', 'watch.config.manage|openvibe.watch']);++ const expectSeeded = ['actor', 'ai', 'bench', 'billing', 'codes', 'host', 'report', 'services', 'watch', ...setupServices.filter((svc) => capabilities.get(`${svc}.config.manage`))]++ .map((svc) => `${svc}.config.manage|openvibe.${svc}`).sort();++ assert.deepStrictEqual(seeded, expectSeeded);assert.ok(await w.db.prepare("SELECT 1 FROM principal_grants WHERE client_id = 'network' AND capability = 'ai.provider.manage' AND audience = 'openvibe.ai' AND revoked_at IS NULL").get(), 'the provider grant is seeded');assert.ok(await w.db.prepare("SELECT 1 FROM principal_grants WHERE client_id = 'host' AND capability = 'ai.usage.read' AND audience = 'openvibe.ai' AND revoked_at IS NULL").get(), 'Host\'s usage grant is seeded');@@@ -127,7 +127,7 @@ const REVIEWED = {'server/stats/open.js fetch': [1, 'open stats (/stats): fixed first-party loopback URLs only — Live (OV_LIVE_INTERNAL_URL, its public home numbers and service-token analytics totals) and Prometheus (PROMETHEUS_URL, loopback by default) with fixed queries; the request only picks 7, 30 or 90 days'],'server/admin/service-proxies.js fetch': [1, 'the admin panel proxies: Live\'s and Chat\'s internal URLs from config, fixed path prefixes, staff only'],'server/admin/review-queue.js fetch': [2, 'the reviewer queue: Wiki/Blog/News/Trade internal URLs from operator config (loopback defaults), fixed /api/v1/review-queue and review paths, ids percent-encoded, the signed-in staff member\'s own token, staff only'],-- 'server/admin/ai-config-proxy.js fetch': [1, 'the owner settings proxy: the nine services\' internal URLs from config.services (operator-set, loopback defaults), fixed /internal/config and AI /api/v1/providers paths, service, namespace and key checked against the known names, owner only'],++ 'server/admin/ai-config-proxy.js fetch': [1, 'the owner settings proxy and the Setup tab (server/admin/setup.js calls through it): the reachable services\' internal URLs from config.services (operator-set, loopback defaults), fixed /internal/config and AI /api/v1/providers paths, service, namespace and key checked against the known names, owner only'],'server/integrations/github.js fetch': [1, 'fixed host api.github.com (asserted below)'],'server/notifications/email-service.js http.request': [1, 'fixed host api.resend.com'],'server/actions/index.js fetch': [4, 'The Action Center: OpenVibe.Actor, OpenVibe.Codes and OpenVibe.AI only (OV_ACTOR_INTERNAL_URL, OV_CODES_INTERNAL_URL, OV_AI_INTERNAL_URL, loopback); the paths are fixed (the decide route and the cancel route), the subject is the signed-in person\'s own usr_ id, and an item id must match [A-Za-z0-9_-]{1,80}'],nnew file mode 100644@@@ -0,0 +1,245 @@++'use strict';++// The owner's Setup tab (server/admin/setup.js; OWNER-SETUP-AND-ADMIN-CLARITY-DESIGN, 2026-11-07) against fake++// services: it lists every field carrying x-owner-input from every reachable service and Network's own to-dos,++// Needed first, with counts that add up; a secret entered through the proxy reaches the service and never Network's++// database or logs; Test shows the service's one-line answer and a failing test makes the row Not working until the++// key changes; a to-do is marked done in network.setup; a service without settings is named in plain words; and++// nobody but the owner gets the tab (no button for an admin, 403 from the API).++// node test/setup.test.js++const assert = require('assert');++const fs = require('fs');++const http = require('http');++const path = require('path');++const crypto = require('crypto');++const jwt = require('jsonwebtoken');++const { buildWorld } = require('./security-world');++const { checkSecretsStored } = require('./security-suite');++++const out = (...a) => process.stdout.write(a.join(' ') + '\n');++const NOW = new Date().toISOString();++const ROOT = path.join(__dirname, '..');++++/** A fake service: records every request and answers from `routes` ({ 'METHOD /path': body | fn | [status, body] }). */++function fake(name) {++ const seen = [];++ const holder = { name, seen, routes: {} };++ holder.srv = http.createServer((req, res) => {++ let text = '';++ req.on('data', (d) => { text += d; });++ req.on('end', () => {++ let body = null;++ try { body = text ? JSON.parse(text) : null; } catch { body = text; }++ const rec = { method: req.method, url: req.url, headers: req.headers, body };++ seen.push(rec);++ const hit = holder.routes[`${req.method} ${req.url.split('?')[0]}`];++ let answer = typeof hit === 'function' ? hit(rec) : hit;++ let status = 200;++ if (Array.isArray(answer)) [status, answer] = answer;++ if (answer === undefined) { status = 404; answer = { code: 'config.route_not_found', error: 'no such route' }; }++ res.writeHead(status, { 'content-type': status >= 400 ? 'application/problem+json' : 'application/json' });++ res.end(JSON.stringify(answer));++ });++ });++ return new Promise((resolve) => holder.srv.listen(0, '127.0.0.1', () => { holder.url = `http://127.0.0.1:${holder.srv.address().port}`; resolve(holder); }));++}++++const fp = (v) => crypto.createHash('sha256').update(`fake|${v}`).digest('hex');++const marker = (v) => ({ redacted: true, fingerprint: fp(v) });++++(async () => {++ const SECRET = 'setup-sentinel-bot-token-6c2e9a-never-stored';++ const SECRET2 = 'setup-sentinel-second-token-1b8f4d';++ const ai = await fake('ai');++ const chat = await fake('chat');++ const deals = await fake('deals');++ const unreachable = 'http://127.0.0.1:9';++ let w = null;++ try {++ w = await buildWorld({++ label: 'setup',++ env: {++ OV_AI_INTERNAL_URL: ai.url, OV_CHAT_INTERNAL_URL: chat.url, OV_DEALS_INTERNAL_URL: deals.url,++ OV_BILLING_INTERNAL_URL: unreachable, OV_SERVICES_INTERNAL_URL: unreachable, OV_WATCH_INTERNAL_URL: unreachable,++ OV_BENCH_INTERNAL_URL: unreachable, OV_REPORT_INTERNAL_URL: unreachable, OV_HOST_INTERNAL_URL: unreachable,++ OV_CODES_INTERNAL_URL: unreachable, OV_ACTOR_INTERNAL_URL: unreachable, OV_CONNECT_INTERNAL_URL: unreachable,++ OV_WORK_INTERNAL_URL: unreachable, OV_SOURCES_INTERNAL_URL: unreachable, OV_DESIGN_INTERNAL_URL: unreachable,++ OV_DOMAINS_INTERNAL_URL: unreachable, OV_MEDIA_INTERNAL_URL: unreachable,++ },++ });++ const owner = w.users.owner;++ const fresh = jwt.sign({ sub: owner.id, id: owner.id, username: owner.username, subject_id: owner.subject, role: owner.role, auth_time: Math.floor(Date.now() / 1000) },++ w.keys.privateKey, { algorithm: 'RS256', issuer: w.issuer, expiresIn: '1h' });++ const ownerCall = (method, p, body) => w.call({ authorization: `Bearer ${fresh}`, cookie: `ov_token=${fresh}` }, method, p, body);++++ // ── What the fakes publish ──++ const LONG = 'A description long enough to say what it does and what the default means.';++ // Chat: a secret bot token (Needed until set, testable), a client id (internal, optional, set) and a field++ // without x-owner-input (never on Setup).++ const chatState = { token: null, revision: 3 };++ const chatSummary = () => ({++ service: 'chat', namespace: 'chat.apps', revision: chatState.revision, sealed: true, pins: [],++ values: { slack_client_id: '1234.5678', answer_mode: 'quiet', ...(chatState.token ? { telegram_bot_token: marker(chatState.token) } : {}) },++ classification: { slack_client_id: 'internal', answer_mode: 'internal', ...(chatState.token ? { telegram_bot_token: 'secret' } : {}) },++ schema: {++ title: 'Chat apps', description: 'The bot tokens the agent uses in chat apps.', type: 'object',++ properties: {++ telegram_bot_token: { type: 'string', title: 'Telegram bot token', description: 'Lets agents answer in Telegram. Without it the Telegram option stays hidden.',++ 'x-owner-input': { group: 'chat-apps', unlocks: 'Lets agents answer in Telegram.', get_it_at: 'https://core.telegram.org/bots#how-do-i-create-a-bot', get_it_how: 'Ask @BotFather for /newbot and copy the token.', test: true },++ 'x-required-for': 'Agents in Telegram' },++ slack_client_id: { type: 'string', title: 'Slack client id', description: LONG, 'x-owner-input': { group: 'sign-in-apps', unlocks: 'Lets people add the agent to Slack.' }, 'x-example': '1234.5678' },++ answer_mode: { type: 'string', title: 'Answer mode', description: LONG, enum: ['quiet', 'chatty'] },++ },++ },++ active: null, last_known_good: null,++ kek: 'k1',++ secrets: chatState.token ? { telegram_bot_token: { redacted: true, fingerprint: fp(chatState.token), hint: chatState.token.slice(-4), set_at: NOW, set_by: { type: 'user', id: owner.subject }, bound_to: null } } : {},++ });++ Object.assign(chat.routes, {++ 'GET /internal/config': () => ({ namespaces: [chatSummary()] }),++ 'GET /internal/config/chat.apps': () => chatSummary(),++ 'PUT /internal/config/chat.apps/secrets/telegram_bot_token': (rec) => { chatState.token = rec.body.value; chatState.revision += 1; return chatSummary().secrets.telegram_bot_token; },++ 'DELETE /internal/config/chat.apps/secrets/telegram_bot_token': () => { chatState.token = null; chatState.revision += 1; return { redacted: true, fingerprint: null, hint: null, set_at: null, set_by: null, bound_to: null }; },++ });++ let chatTest = { ok: false, message: 'Telegram said 401 Unauthorized:\n the token was refused' };++ chat.routes['POST /internal/config/chat.apps/test/telegram_bot_token'] = () => chatTest;++ // AI: a GPU key (secret, Needed, unset) and a monthly cap (a money number, optional, unset).++ Object.assign(ai.routes, {++ 'GET /internal/config': {++ namespaces: [{++ service: 'ai', namespace: 'ai.gpus', revision: 2, sealed: true, pins: [], values: {}, classification: {},++ schema: {++ title: 'OpenVibe GPUs', type: 'object', properties: {++ 'vast.key': { type: 'string', title: 'Vast.ai API key', description: LONG, 'x-owner-input': { group: 'gpus', unlocks: 'Lets OpenVibe rent GPUs on Vast.ai.', get_it_at: 'https://cloud.vast.ai/account/', get_it_how: 'Create an API key with the instance permissions.' }, 'x-required-for': 'Renting GPUs' },++ monthly_cap: { type: 'integer', title: 'Monthly GPU cap', description: LONG, 'x-unit': 'nano_usd', 'x-danger': true, 'x-owner-input': { group: 'gpus', unlocks: 'Caps what GPU rentals may cost in a month.' } },++ region: { type: 'string', title: 'Region', description: LONG },++ },++ },++ active: null, last_known_good: null,++ }],++ },++ });++ // Deals has not mounted the config routes yet.++ deals.routes['GET /internal/config'] = [404, { code: 'not_found', error: 'Not found' }];++++ // ── Only the owner: a user and a plain admin get 403, and nothing reaches a service ──++ let r = await w.call('alice', 'GET', '/api/admin/setup');++ assert.strictEqual(r.status, 403, 'a user never gets Setup');++ r = await w.call('staff', 'GET', '/api/admin/setup');++ assert.strictEqual(r.status, 403, 'a plain admin never gets Setup');++ r = await w.call('staff', 'POST', '/api/admin/setup/tasks/dmca_agent', { done: true });++ assert.strictEqual(r.status, 403, 'a plain admin cannot mark a to-do');++ // Only config calls count: the server's own boot-time calls (navbar copy and the like) may reach a fake too.++ const configCalls = () => [...ai.seen, ...chat.seen, ...deals.seen].filter((x) => x.url.startsWith('/internal/config'));++ assert.strictEqual(configCalls().length, 0, 'a refused request never reaches a service');++++ // ── The page: the Setup tab is the first button, owner only, and the tab is in the owner-only set ──++ const html = fs.readFileSync(path.join(ROOT, 'public', 'admin.html'), 'utf8');++ 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"'));++ 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');++++ // ── The checklist: every x-owner-input field and Network's own to-dos, Needed first, counts that add up ──++ r = await ownerCall('GET', '/api/admin/setup');++ assert.strictEqual(r.status, 200, r.text);++ const keys = r.body.rows.filter((x) => x.kind === 'key').map((x) => x.id).sort();++ assert.deepStrictEqual(keys, ['ai/ai.gpus/monthly_cap', 'ai/ai.gpus/vast.key', 'chat/chat.apps/slack_client_id', 'chat/chat.apps/telegram_bot_token'], 'every field with x-owner-input, and only those');++ const tasks = r.body.rows.filter((x) => x.kind === 'task').map((x) => x.key).sort();++ assert.deepStrictEqual(tasks, ['csam_scanning', 'dmca_agent', 'dnssec_records', 'ncmec_registration', 'provider_topup', 'public_suffix_list']);++ const rank = { needed: 0, not_working: 1, connected: 2, optional: 3 };++ r.body.rows.forEach((row, i) => { if (i) assert.ok(rank[r.body.rows[i - 1].state] <= rank[row.state], 'rows are Needed, Not working, Connected, Optional'); });++ assert.strictEqual(r.body.rows[0].state, 'needed', 'Needed sorts first');++ assert.strictEqual(r.body.groups[0].rows[0].state, 'needed', 'the page opens on a group with something that needs the owner');++ const c = r.body.counts;++ assert.strictEqual(c.needed + c.not_working + c.connected + c.optional, c.total, 'the counts add up');++ assert.strictEqual(c.total, r.body.rows.length);++ assert.strictEqual(r.body.groups.reduce((n, g) => n + g.rows.length, 0), c.total, 'every row is in one group');++ const row = (id) => r.body.rows.find((x) => x.id === id);++ let tg = row('chat/chat.apps/telegram_bot_token');++ assert.deepStrictEqual({ state: tg.state, required_for: tg.required_for, group: tg.group, secret: tg.secret, testable: tg.testable, set: tg.set, get_it_at: tg.get_it_at },++ { state: 'needed', required_for: 'Agents in Telegram', group: 'chat-apps', secret: true, testable: true, set: false, get_it_at: 'https://core.telegram.org/bots#how-do-i-create-a-bot' },++ 'an unset token an older service leaves unclassified is still taken for a secret');++ assert.strictEqual(row('ai/ai.gpus/vast.key').secret, true, 'so is an unset key');++ assert.strictEqual(row('chat/chat.apps/slack_client_id').secret, false, 'a client id is not');++ assert.strictEqual(row('chat/chat.apps/slack_client_id').state, 'connected', 'a plain value that is set and never failed is Connected');++ assert.strictEqual(row('chat/chat.apps/slack_client_id').value, '1234.5678', 'a plain value is shown');++ assert.strictEqual(row('ai/ai.gpus/monthly_cap').state, 'optional', 'unset and nothing waits on it: Optional');++ assert.strictEqual(row('ai/ai.gpus/monthly_cap').unit, 'nano_usd');++ assert.strictEqual(row('ai/ai.gpus/vast.key').state, 'needed');++ assert.strictEqual(row(`network/network.setup/ncmec_registration`).state, 'needed', 'a legal to-do that something waits on is Needed');++ assert.strictEqual(row(`network/network.setup/dnssec_records`).state, 'optional');++ const dealsNote = r.body.services.find((s) => s.service === 'deals');++ assert.deepStrictEqual({ answered: dealsNote.answered, note: dealsNote.note }, { answered: false, note: 'OpenVibe.Deals has not published settings yet.' }, 'a service without the routes is named in plain words');++ assert.strictEqual(r.body.services.find((s) => s.service === 'media').note, 'OpenVibe.Media has not published settings yet.', 'one that does not answer too');++ assert.ok(r.body.empty_groups.some((g) => g.id === 'voices'), 'a group no service fills yet is listed as empty');++++ // ── A secret entered on Setup: through the proxy to the service's vault, then Connected with its hint ──++ r = await ownerCall('PUT', '/api/admin/ai/config/chat/chat.apps/secrets/telegram_bot_token', { value: SECRET, reason: 'Entered on Setup' });++ assert.strictEqual(r.status, 200, r.text);++ assert.ok(!r.text.includes(SECRET), 'the answer is the marker, never the value');++ const put = chat.seen.filter((x) => x.method === 'PUT').at(-1);++ assert.strictEqual(put.body.value, SECRET, 'the service gets the value once');++ assert.strictEqual(put.headers['x-ov-fresh'], '1');++ r = await ownerCall('GET', '/api/admin/setup');++ tg = r.body.rows.find((x) => x.id === 'chat/chat.apps/telegram_bot_token');++ assert.deepStrictEqual({ state: tg.state, set: tg.set, secret: tg.secret, hint: tg.hint, last_test: tg.last_test }, { state: 'connected', set: true, secret: true, hint: SECRET.slice(-4), last_test: null });++ assert.ok(!r.text.includes(SECRET), 'Setup never shows the value');++++ // ── Test: the service's one-line answer; a failure makes the row Not working ──++ r = await ownerCall('POST', '/api/admin/ai/config/chat/chat.apps/test/telegram_bot_token', {});++ assert.strictEqual(r.status, 200, r.text);++ assert.deepStrictEqual({ passed: r.body.passed, message: r.body.message }, { passed: false, message: 'Telegram said 401 Unauthorized: the token was refused' });++ r = await ownerCall('GET', '/api/admin/setup');++ tg = r.body.rows.find((x) => x.id === 'chat/chat.apps/telegram_bot_token');++ assert.strictEqual(tg.state, 'not_working', 'a failed test is Not working');++ assert.strictEqual(tg.last_test.message, 'Telegram said 401 Unauthorized: the token was refused', 'with the provider\'s answer in one line');++ assert.strictEqual(r.body.counts.not_working, 1);++ assert.strictEqual(r.body.rows.find((x) => x.state !== 'needed').id, 'chat/chat.apps/telegram_bot_token', 'Not working comes right after Needed');++++ // A new key: the old failure no longer counts.++ r = await ownerCall('PUT', '/api/admin/ai/config/chat/chat.apps/secrets/telegram_bot_token', { value: SECRET2, reason: 'Replaced on Setup' });++ assert.strictEqual(r.status, 200, r.text);++ r = await ownerCall('GET', '/api/admin/setup');++ tg = r.body.rows.find((x) => x.id === 'chat/chat.apps/telegram_bot_token');++ assert.deepStrictEqual({ state: tg.state, last_test: tg.last_test }, { state: 'connected', last_test: null }, 'a replaced key is not judged by the old key\'s test');++ chatTest = { ok: true, message: 'Telegram knows the bot @openvibe_bot.' };++ r = await ownerCall('POST', '/api/admin/ai/config/chat/chat.apps/test/telegram_bot_token', {});++ assert.deepStrictEqual({ passed: r.body.passed, message: r.body.message }, { passed: true, message: 'Telegram knows the bot @openvibe_bot.' });++ r = await ownerCall('GET', '/api/admin/setup');++ tg = r.body.rows.find((x) => x.id === 'chat/chat.apps/telegram_bot_token');++ assert.deepStrictEqual({ state: tg.state, passed: tg.last_test.passed }, { state: 'connected', passed: true });++++ // ── Network's own to-dos: Mark done, kept in network.setup, and undone ──++ r = await ownerCall('POST', '/api/admin/setup/tasks/ncmec_registration', { done: true });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.body.row.state, 'connected');++ assert.strictEqual(r.body.row.done.by, owner.username);++ r = await ownerCall('GET', '/api/admin/setup');++ assert.strictEqual(r.body.rows.find((x) => x.key === 'ncmec_registration').state, 'connected', 'a to-do marked done stays done');++ const snap = await w.db.prepare("SELECT values_json, created_by FROM config_snapshots WHERE namespace = 'network.setup' AND state = 'active'").get();++ assert.ok(JSON.parse(snap.values_json).ncmec_registration.done_at, 'it is a revision of network.setup');++ assert.match(String(snap.created_by), new RegExp(owner.subject), 'made by the owner');++ r = await ownerCall('POST', '/api/admin/setup/tasks/ncmec_registration', { done: false });++ assert.strictEqual(r.body.row.state, 'needed', 'and it can be undone');++ r = await ownerCall('POST', '/api/admin/setup/tasks/no_such_thing', { done: true });++ assert.strictEqual(r.status, 404);++ r = await ownerCall('POST', '/api/admin/setup/tasks/dmca_agent', { done: 'yes' });++ assert.strictEqual(r.status, 400);++++ // ── Network's own namespace reads clearly (lintSchema, openvibe-shared 4.125.0) ──++ const sharedConfig = require('openvibe-shared/config');++ const { TASKS_SCHEMA } = require('../server/admin/setup');++ if (typeof sharedConfig.lintSchema === 'function') assert.deepStrictEqual(sharedConfig.lintSchema(TASKS_SCHEMA), [], 'every to-do has a title and a clear description');++ else for (const [k, p] of Object.entries(TASKS_SCHEMA.properties)) assert.ok(p.title && p.description.length >= 30, `${k} reads clearly`);++++ // ── No secret in a Network table or log line ──++ await checkSecretsStored(w, () => ({ 'setup secret': SECRET, 'setup second secret': SECRET2 }));++++ out('setup: all checks passed');++ } finally {++ if (w) await w.stop();++ for (const f of [ai, chat, deals]) await new Promise((res) => f.srv.close(res));++ }++})().then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });
@@ -20,6 +20,7 @@ Identity and account service for the OpenVibe network. Manages user accounts, OA
@@ -20,6 +20,7 @@ Identity and account service for the OpenVibe network. Manages user accounts, OA
- **Theme Catalog** — Shared theme system with ~30 built-in themes and community submissions. Theme preferences sync across all services.
- **Theme Catalog** — Shared theme system with ~30 built-in themes and community submissions. Theme preferences sync across all services.
- **Per-site theme overrides** — one theme follows a person to every site, and an override is their exception for one service ("Games in Neon Tokyo", "Live in Daylight"). `GET /api/themes/me/active?service=<id>` answers that service's theme (`override: true`, with the `service` named) or the person's global theme (`override: false`); a missing or unknown service answers exactly as before. The person owns their own list at `GET /api/themes/me/overrides`, `PUT /api/themes/me/overrides/:service` and `DELETE /api/themes/me/overrides/:service` (the theme and the custom colours are validated as `PUT /api/themes/me` does), at most 50 per person, and every change is kept in the theme history with the service recorded.
- **Per-site theme overrides** — one theme follows a person to every site, and an override is their exception for one service ("Games in Neon Tokyo", "Live in Daylight"). `GET /api/themes/me/active?service=<id>` answers that service's theme (`override: true`, with the `service` named) or the person's global theme (`override: false`); a missing or unknown service answers exactly as before. The person owns their own list at `GET /api/themes/me/overrides`, `PUT /api/themes/me/overrides/:service` and `DELETE /api/themes/me/overrides/:service` (the theme and the custom colours are validated as `PUT /api/themes/me` does), at most 50 per person, and every change is kept in the theme history with the service recorded.
- **Admin Panel** — email configuration, user management (role changes, bans), broadcast notifications, system health dashboard, and audit log.
- **Admin Panel** — email configuration, user management (role changes, bans), broadcast notifications, system health dashboard, and audit log.
+- **Setup** (`/admin#setup`, owner only) — one checklist of everything only the owner can give the network: keys, accounts, caps and a few registrations. It lists every field a service's config namespace marks `x-owner-input` (openvibe-shared 4.125.0), reached through the owner settings proxy (`server/admin/ai-config-proxy.js`, seventeen services), Needed first, then Not working, Connected and Optional, with a counted summary. A key is written once through the proxy (fresh sign-in, the service's vault, audit, alert) and never shown again; **Test** asks the service to try it and keeps the one-line answer; Network's own to-dos (legal registrations, DNSSEC at the registrar, a top-up) are its `network.setup` namespace with **Mark done** (`server/admin/setup.js`). The AI tab and every admin page explain their words; `/admin#glossary` lists them.
- **Reviewer queue** (`/admin/review`) — one staff-only page for every Wiki page, Blog draft, AI brief and Trade company page waiting for a person, grouped by site with the publishing gate's noindex reason in words. Approve (indexable) or keep hidden calls the owning service's own review route and writes its own review log.
- **Reviewer queue** (`/admin/review`) — one staff-only page for every Wiki page, Blog draft, AI brief and Trade company page waiting for a person, grouped by site with the publishing gate's noindex reason in words. Approve (indexable) or keep hidden calls the owning service's own review route and writes its own review log.
- **Internal API** — Server-to-server endpoints for token verification, user lookup, notification push, account linking, and audit logging. Every route takes a capability-scoped service token; the deployment-wide key they used to accept was retired on 2026-09-29 (plan T2, [docs/retirement.md](docs/retirement.md)) and opens nothing.
- **Internal API** — Server-to-server endpoints for token verification, user lookup, notification push, account linking, and audit logging. Every route takes a capability-scoped service token; the deployment-wide key they used to accept was retired on 2026-09-29 (plan T2, [docs/retirement.md](docs/retirement.md)) and opens nothing.
- **Developer projects (foundation)** — projects, members, apps (OAuth clients with `app_` subjects), hashed client secrets with rotation and revocation, capability grants within a staff-set allowance (sandbox apps also get a default sandbox allowance of public Media, Events-app and Tools-job capabilities, so a new project works without staff), recorded quotas, per-project usage from the services' hourly rollups (`GET /api/v1/projects/:project/usage`) and an append-only audit at `/api/v1/projects`. Its events can be relayed to OpenVibe.Events through an outbox when `OV_EVENTS_INTERNAL_URL` is set. Bearer user tokens only; the console over this API is OpenVibe.Services at openvibe.services (it moved there from OpenVibe.Codes on 2026-10-08). See [docs/developer-projects.md](docs/developer-projects.md).
- **Developer projects (foundation)** — projects, members, apps (OAuth clients with `app_` subjects), hashed client secrets with rotation and revocation, capability grants within a staff-set allowance (sandbox apps also get a default sandbox allowance of public Media, Events-app and Tools-job capabilities, so a new project works without staff), recorded quotas, per-project usage from the services' hourly rollups (`GET /api/v1/projects/:project/usage`) and an append-only audit at `/api/v1/projects`. Its events can be relayed to OpenVibe.Events through an outbox when `OV_EVENTS_INTERNAL_URL` is set. Bearer user tokens only; the console over this API is OpenVibe.Services at openvibe.services (it moved there from OpenVibe.Codes on 2026-10-08). See [docs/developer-projects.md](docs/developer-projects.md).
@@ -47,6 +47,7 @@
@@ -47,6 +47,7 @@
</button>
</button>
<div class="section-tabs-wrap">
<div class="section-tabs-wrap">
<div class="section-tabs" id="main-tabs">
<div class="section-tabs" id="main-tabs">
+ <button class="admin-tab-btn owner-only" onclick="showTab('setup',this)"><i class="fa-solid fa-list-check"></i> Setup</button>
<button class="admin-tab-btn active" onclick="showTab('dashboard',this)"><i class="fa-solid fa-chart-line"></i> Dashboard</button>
<button class="admin-tab-btn active" onclick="showTab('dashboard',this)"><i class="fa-solid fa-chart-line"></i> Dashboard</button>
<button class="admin-tab-btn" onclick="location.href='/admin/review'"><i class="fa-solid fa-list-check"></i> Review queue</button>
<button class="admin-tab-btn" onclick="location.href='/admin/review'"><i class="fa-solid fa-list-check"></i> Review queue</button>
<button class="admin-tab-btn" onclick="showTab('users',this)"><i class="fa-solid fa-users"></i> Users</button>
<button class="admin-tab-btn" onclick="showTab('users',this)"><i class="fa-solid fa-users"></i> Users</button>
@@ -75,6 +76,7 @@
@@ -75,6 +76,7 @@
<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 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('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">
@@ -82,6 +84,16 @@
@@ -82,6 +84,16 @@
</button>
</button>
</div>
</div>
+ <!-- Setup (owner only): everything only the owner can give the network, from every service (js/admin/setup.js) -->
+ <div class="section" id="sec-setup">
+ <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 -->
<!-- Dashboard -->
<div class="section active" id="sec-dashboard">
<div class="section active" id="sec-dashboard">
<div class="stats-grid" id="dashboard-stats"><div class="loading">Loading stats...</div></div>
<div class="stats-grid" id="dashboard-stats"><div class="loading">Loading stats...</div></div>
@@ -647,7 +659,9 @@
@@ -647,7 +659,9 @@
<script src="/js/admin/moderation.js" defer></script>
<script src="/js/admin/moderation.js" defer></script>
<script src="/js/admin/settings.js" defer></script>
<script src="/js/admin/settings.js" defer></script>
<script src="/js/admin/payments.js" defer></script>
<script src="/js/admin/payments.js" defer></script>
+<script src="/js/admin/glossary.js" defer></script>
<script src="/js/admin/ai.js" defer></script>
<script src="/js/admin/ai.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/deploy.js" defer></script>
@@ -192,21 +192,21 @@ a.btn{text-decoration:none}
@@ -192,21 +192,21 @@ a.btn{text-decoration:none}
.pai-provider-meta{display:flex;flex-wrap:wrap;align-items:center;gap:7px;margin-top:5px;font-size:11.5px;color:var(--text-muted)}
.pai-provider-meta{display:flex;flex-wrap:wrap;align-items:center;gap:7px;margin-top:5px;font-size:11.5px;color:var(--text-muted)}
.pai-provider-meta code{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:11px;color:var(--text-secondary)}
.pai-provider-meta code{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:11px;color:var(--text-secondary)}
.pai-access{font-size:10px;font-weight:700;padding:2px 7px;border-radius:999px;border:1px solid transparent}
.pai-access{font-size:10px;font-weight:700;padding:2px 7px;border-radius:999px;border:1px solid transparent}
-.pai-access.is-own_deployment{color:var(--success);background:color-mix(in srgb,var(--success) 15%,transparent);border-color:color-mix(in srgb,var(--success) 35%,transparent)}
+.pai-access.is-own_deployment{color:var(--success-text,var(--success));background:color-mix(in srgb,var(--success) 15%,transparent);border-color:color-mix(in srgb,var(--success) 35%,transparent)}
.pai-access.is-authorized_wholesale{color:var(--accent-light);background:color-mix(in srgb,var(--accent) 16%,transparent);border-color:color-mix(in srgb,var(--accent) 38%,transparent)}
.pai-access.is-authorized_wholesale{color:var(--accent-light);background:color-mix(in srgb,var(--accent) 16%,transparent);border-color:color-mix(in srgb,var(--accent) 38%,transparent)}
.pai-access.is-customer_byok{color:#a78bfa;background:color-mix(in srgb,#8b5cf6 17%,transparent);border-color:color-mix(in srgb,#8b5cf6 40%,transparent)}
.pai-access.is-customer_byok{color:#a78bfa;background:color-mix(in srgb,#8b5cf6 17%,transparent);border-color:color-mix(in srgb,#8b5cf6 40%,transparent)}
-.pai-access.is-restricted{color:var(--warning);background:color-mix(in srgb,var(--warning) 14%,transparent);border-color:color-mix(in srgb,var(--warning) 38%,transparent)}
+.pai-access.is-restricted{color:var(--warning-text,var(--warning));background:color-mix(in srgb,var(--warning) 14%,transparent);border-color:color-mix(in srgb,var(--warning) 38%,transparent)}
.pai-access.is-unverified{color:var(--text-muted);background:color-mix(in srgb,var(--text-muted) 14%,transparent);border-color:color-mix(in srgb,var(--text-muted) 32%,transparent)}
.pai-access.is-unverified{color:var(--text-muted);background:color-mix(in srgb,var(--text-muted) 14%,transparent);border-color:color-mix(in srgb,var(--text-muted) 32%,transparent)}
.pai-billing{font-size:11px;color:var(--text-secondary)}
.pai-billing{font-size:11px;color:var(--text-secondary)}
.pai-keystate{margin-top:5px;font-size:11.5px;font-family:ui-monospace,Menlo,Consolas,monospace}
.pai-keystate{margin-top:5px;font-size:11.5px;font-family:ui-monospace,Menlo,Consolas,monospace}
-.pai-keystate.is-ok{color:var(--success)}
+.pai-keystate.is-ok{color:var(--success-text,var(--success))}
.pai-keystate.is-muted{color:var(--text-muted)}
.pai-keystate.is-muted{color:var(--text-muted)}
-.pai-keystate.is-bad{color:var(--danger)}
+.pai-keystate.is-bad{color:var(--danger-text,var(--danger))}
.pai-provider-actions{display:flex;flex-wrap:wrap;align-items:center;justify-content:flex-end;gap:6px;max-width:430px}
.pai-provider-actions{display:flex;flex-wrap:wrap;align-items:center;justify-content:flex-end;gap:6px;max-width:430px}
.pai .btn.pai-quiet{background:transparent;border:1px solid var(--border);color:var(--text-secondary)}
.pai .btn.pai-quiet{background:transparent;border:1px solid var(--border);color:var(--text-secondary)}
.pai .btn.pai-quiet:hover{border-color:var(--accent);color:var(--text)}
.pai .btn.pai-quiet:hover{border-color:var(--accent);color:var(--text)}
-.pai .btn.pai-quiet.pai-quiet-danger{color:var(--danger);border-color:color-mix(in srgb,var(--danger) 45%,var(--border));background:transparent}
-.pai .btn.pai-quiet.pai-quiet-danger:hover{background:color-mix(in srgb,var(--danger) 10%,transparent);border-color:var(--danger);color:var(--danger)}
+.pai .btn.pai-quiet.pai-quiet-danger{color:var(--danger-text,var(--danger));border-color:color-mix(in srgb,var(--danger) 45%,var(--border));background:transparent}
+.pai .btn.pai-quiet.pai-quiet-danger:hover{background:color-mix(in srgb,var(--danger) 10%,transparent);border-color:var(--danger);color:var(--danger-text,var(--danger))}
.pai-switch{display:inline-flex;align-items:center;gap:8px;font-size:12px;color:var(--text-secondary);min-height:34px;padding-right:4px;cursor:pointer;user-select:none}
.pai-switch{display:inline-flex;align-items:center;gap:8px;font-size:12px;color:var(--text-secondary);min-height:34px;padding-right:4px;cursor:pointer;user-select:none}
.pai-switch input{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}
.pai-switch input{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}
.pai-track{position:relative;display:inline-block;width:36px;height:20px;flex:0 0 36px;border-radius:10px;background:var(--border);border:1px solid var(--border);transition:background-color .16s ease,border-color .16s ease}
.pai-track{position:relative;display:inline-block;width:36px;height:20px;flex:0 0 36px;border-radius:10px;background:var(--border);border:1px solid var(--border);transition:background-color .16s ease,border-color .16s ease}
@@ -233,16 +233,16 @@ a.btn{text-decoration:none}
@@ -233,16 +233,16 @@ a.btn{text-decoration:none}
.pai-field-desc+.pai-control{margin-top:6px}
.pai-field-desc+.pai-control{margin-top:6px}
.pai-chip-lock,.pai-chip-danger,.pai-chip-secret{font-size:10px;font-weight:700;padding:2px 7px;border-radius:999px;border:1px solid transparent}
.pai-chip-lock,.pai-chip-danger,.pai-chip-secret{font-size:10px;font-weight:700;padding:2px 7px;border-radius:999px;border:1px solid transparent}
.pai-chip-lock{color:var(--text-muted);background:var(--bg-tertiary);border-color:var(--border)}
.pai-chip-lock{color:var(--text-muted);background:var(--bg-tertiary);border-color:var(--border)}
-.pai-chip-danger{color:var(--warning);background:color-mix(in srgb,var(--warning) 15%,transparent);border-color:color-mix(in srgb,var(--warning) 40%,transparent)}
+.pai-chip-danger{color:var(--warning-text,var(--warning));background:color-mix(in srgb,var(--warning) 15%,transparent);border-color:color-mix(in srgb,var(--warning) 40%,transparent)}
.pai-chip-secret{color:#a78bfa;background:color-mix(in srgb,#8b5cf6 14%,transparent);border-color:color-mix(in srgb,#8b5cf6 36%,transparent)}
.pai-chip-secret{color:#a78bfa;background:color-mix(in srgb,#8b5cf6 14%,transparent);border-color:color-mix(in srgb,#8b5cf6 36%,transparent)}
.pai-pin-line{font-size:11px;color:var(--text-muted);margin-top:2px}
.pai-pin-line{font-size:11px;color:var(--text-muted);margin-top:2px}
-.pai-marker{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12px;color:var(--success);padding:7px 0;display:inline-block}
+.pai-marker{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12px;color:var(--success-text,var(--success));padding:7px 0;display:inline-block}
.pai-secret{display:flex;align-items:center;gap:7px;flex-wrap:wrap}
.pai-secret{display:flex;align-items:center;gap:7px;flex-wrap:wrap}
.pai-chips{display:flex;flex-wrap:wrap;gap:5px;min-height:26px}
.pai-chips{display:flex;flex-wrap:wrap;gap:5px;min-height:26px}
.pai-chip{display:inline-flex;align-items:center;gap:4px;padding:4px 8px;border-radius:999px;background:var(--bg-tertiary);border:1px solid var(--border);font-size:11.5px;color:var(--text-secondary);max-width:100%}
.pai-chip{display:inline-flex;align-items:center;gap:4px;padding:4px 8px;border-radius:999px;background:var(--bg-tertiary);border:1px solid var(--border);font-size:11.5px;color:var(--text-secondary);max-width:100%}
.pai-chip>span{overflow-wrap:anywhere}
.pai-chip>span{overflow-wrap:anywhere}
.pai-chip button{border:0;background:none;color:var(--text-muted);cursor:pointer;font-size:14px;line-height:1;padding:2px 3px}
.pai-chip button{border:0;background:none;color:var(--text-muted);cursor:pointer;font-size:14px;line-height:1;padding:2px 3px}
-.pai-chip button:hover{color:var(--danger)}
+.pai-chip button:hover{color:var(--danger-text,var(--danger))}
.pai-list-add{display:flex;gap:6px;align-items:center}
.pai-list-add{display:flex;gap:6px;align-items:center}
.pai-list-add input{flex:1;min-width:0}
.pai-list-add input{flex:1;min-width:0}
.pai-money-input,.pai-unit-input{display:flex;align-items:center;gap:0;position:relative}
.pai-money-input,.pai-unit-input{display:flex;align-items:center;gap:0;position:relative}
@@ -256,8 +256,8 @@ a.btn{text-decoration:none}
@@ -256,8 +256,8 @@ a.btn{text-decoration:none}
.pai-vault-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px;padding:10px 12px;background:var(--bg-card);border:1px solid var(--border);border-radius:9px}
.pai-vault-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px;padding:10px 12px;background:var(--bg-card);border:1px solid var(--border);border-radius:9px}
.pai-vault-name{font-size:13px;font-weight:700;min-width:140px}
.pai-vault-name{font-size:13px;font-weight:700;min-width:140px}
.pai-state{font-size:11.5px;font-weight:700;padding:2px 8px;border-radius:999px}
.pai-state{font-size:11.5px;font-weight:700;padding:2px 8px;border-radius:999px}
-.pai-state.is-ok{color:var(--success);background:color-mix(in srgb,var(--success) 15%,transparent)}
-.pai-state.is-bad{color:var(--danger);background:color-mix(in srgb,var(--danger) 15%,transparent)}
+.pai-state.is-ok{color:var(--success-text,var(--success));background:color-mix(in srgb,var(--success) 15%,transparent)}
+.pai-state.is-bad{color:var(--danger-text,var(--danger));background:color-mix(in srgb,var(--danger) 15%,transparent)}
.pai-state.is-muted{color:var(--text-muted);background:color-mix(in srgb,var(--text-muted) 14%,transparent)}
.pai-state.is-muted{color:var(--text-muted);background:color-mix(in srgb,var(--text-muted) 14%,transparent)}
.pai-vault-row code{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:11px;color:var(--text-secondary)}
.pai-vault-row code{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:11px;color:var(--text-secondary)}
.pai-hint{flex-basis:100%;font-size:11px;color:var(--text-muted)}
.pai-hint{flex-basis:100%;font-size:11px;color:var(--text-muted)}
@@ -279,3 +279,99 @@ a.btn{text-decoration:none}
@@ -279,3 +279,99 @@ a.btn{text-decoration:none}
.pai-table td{padding:7px 8px;border-bottom:1px solid var(--border);vertical-align:top;overflow-wrap:anywhere}
.pai-table td{padding:7px 8px;border-bottom:1px solid var(--border);vertical-align:top;overflow-wrap:anywhere}
@media(max-width:800px){.pai-provider{grid-template-columns:minmax(0,1fr)}.pai-provider-actions{justify-content:flex-start;max-width:none}.pai-fields{grid-template-columns:minmax(0,1fr)}}
@media(max-width:800px){.pai-provider{grid-template-columns:minmax(0,1fr)}.pai-provider-actions{justify-content:flex-start;max-width:none}.pai-fields{grid-template-columns:minmax(0,1fr)}}
@media(max-width:640px){.pai-form-row{grid-template-columns:minmax(0,1fr)}#platform-ai .btn,#platform-ai input,#platform-ai select,#platform-ai textarea,#platform-ai summary,#platform-ai .pai-index a,#platform-ai .pai-switch{min-height:44px}#platform-ai .pai-chip button{min-width:44px;min-height:44px}.pai-index{gap:2px;padding:5px}.pai-index a{padding:8px 9px;font-size:11.5px}.pai-section{scroll-margin-top:96px}.pai-dialog{width:calc(100vw - 16px)}.pai-form{padding:14px}.pai-card{padding:13px}}
@media(max-width:640px){.pai-form-row{grid-template-columns:minmax(0,1fr)}#platform-ai .btn,#platform-ai input,#platform-ai select,#platform-ai textarea,#platform-ai summary,#platform-ai .pai-index a,#platform-ai .pai-switch{min-height:44px}#platform-ai .pai-chip button{min-width:44px;min-height:44px}.pai-index{gap:2px;padding:5px}.pai-index a{padding:8px 9px;font-size:11.5px}.pai-section{scroll-margin-top:96px}.pai-dialog{width:calc(100vw - 16px)}.pai-form{padding:14px}.pai-card{padding:13px}}
+
+/* ── The clear admin panel (2026-11-07): machine keys small and grey, Advanced settings folds, forms for objects ── */
+.pai-mkey{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:10.5px;font-weight:500;color:var(--text-muted);background:var(--bg-tertiary);padding:1px 6px;border-radius:5px;vertical-align:middle;overflow-wrap:anywhere}
+.pai-words{font-size:12px;margin-top:4px}
+.pai-advanced{margin-top:14px;border-top:1px solid var(--border);padding-top:8px}
+.pai-advanced>summary{cursor:pointer;font-size:12.5px;font-weight:700;color:var(--accent-light);min-height:34px;display:flex;align-items:center}
+.pai-subform{display:grid;gap:10px;padding:10px;border:1px solid var(--border);border-radius:9px;background:var(--bg-secondary)}
+.pai-subfield{display:grid;gap:4px;min-width:0}
+.pai-subfield-label{font-size:12px;font-weight:600;display:flex;flex-wrap:wrap;gap:6px;align-items:center}
+.pai-subfield-label code{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:10.5px;font-weight:500;color:var(--text-muted)}
+.pai-unit-line{font-size:11px}
+.ov-what{font-size:11.5px;font-weight:600;color:var(--accent-light);text-decoration:underline;text-underline-offset:2px;white-space:nowrap}
+.ov-what:hover,.ov-what:focus-visible{color:var(--text)}
+.ov-what-text{font-size:13.5px;line-height:1.6;color:var(--text)}
+
+/* ── Setup (owner only): one checklist, Needed first ─────────────────────────────────────────────────────── */
+.setup{max-width:1100px;min-width:0;overflow-wrap:anywhere}
+.setup-head{margin-bottom:18px}
+.setup-head h2{font-size:20px;font-weight:800;display:flex;align-items:center;gap:9px;margin-bottom:6px}
+.setup-head>p{max-width:820px;font-size:13px;line-height:1.55}
+.setup-summary{margin:12px 0 6px;padding:10px 14px;border-radius:10px;background:var(--bg-card);border:1px solid var(--border);font-size:14px;color:var(--text)}
+.setup-help{font-size:12px;margin:6px 0 10px}
+.setup-group{margin:0 0 22px;scroll-margin-top:64px}
+.setup-group>h3{font-size:15px;font-weight:800;margin-bottom:8px;display:flex;flex-wrap:wrap;align-items:center;gap:8px}
+.setup-count{font-size:11px;font-weight:700;padding:2px 8px;border-radius:999px}
+.setup-count.is-bad{color:var(--danger-text,var(--danger));background:color-mix(in srgb,var(--danger) 14%,transparent)}
+.setup-count.is-warn{color:var(--warning-text,var(--warning));background:color-mix(in srgb,var(--warning) 15%,transparent)}
+.setup-rows{list-style:none;display:grid;gap:8px}
+.setup-row{display:grid;grid-template-columns:minmax(0,1fr) minmax(240px,340px);gap:10px 18px;align-items:start;padding:13px 14px;background:var(--bg-card);border:1px solid var(--border);border-left-width:4px;border-radius:10px}
+.setup-row.is-needed{border-left-color:var(--danger)}
+.setup-row.is-not_working{border-left-color:var(--warning)}
+.setup-row.is-connected{border-left-color:var(--success)}
+.setup-row.is-optional{border-left-color:var(--border)}
+.setup-row-main{min-width:0}
+.setup-row-title{display:flex;flex-wrap:wrap;align-items:center;gap:8px;font-size:14px}
+.setup-state{font-size:11px;font-weight:700;padding:2px 8px;border-radius:999px}
+.setup-state.is-bad{color:var(--danger-text,var(--danger));background:color-mix(in srgb,var(--danger) 15%,transparent)}
+.setup-state.is-warn{color:var(--warning-text,var(--warning));background:color-mix(in srgb,var(--warning) 16%,transparent)}
+.setup-state.is-ok{color:var(--success-text,var(--success));background:color-mix(in srgb,var(--success) 15%,transparent)}
+.setup-state.is-muted{color:var(--text-muted);background:color-mix(in srgb,var(--text-muted) 14%,transparent)}
+.setup-what{font-size:12.5px;line-height:1.5;color:var(--text-secondary);margin-top:4px}
+.setup-state-line{font-size:12px;line-height:1.5;margin-top:5px}
+.setup-state-line.is-bad{color:var(--danger-text,var(--danger))}
+.setup-state-line.is-warn{color:var(--warning-text,var(--warning))}
+.setup-state-line.is-ok{color:var(--success-text,var(--success))}
+.setup-state-line.is-muted{color:var(--text-muted)}
+.setup-where{font-size:12px;line-height:1.5;margin-top:5px;color:var(--text-secondary)}
+.setup-where a{color:var(--accent-light);font-weight:600}
+.setup-from{font-size:11px;color:var(--text-muted);margin-top:6px;display:flex;flex-wrap:wrap;gap:5px;align-items:center}
+.setup-from code{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:10.5px;color:var(--text-muted);background:var(--bg-tertiary);padding:1px 6px;border-radius:5px;overflow-wrap:anywhere}
+.setup-row-actions{display:grid;gap:8px;min-width:0}
+.setup-row-actions>.btn{justify-self:start}
+.setup-input{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px;align-items:end;min-width:0}
+.setup-input-label{grid-column:1/-1;font-size:11.5px;font-weight:600;color:var(--text-secondary)}
+.setup-input input:not([type=checkbox]){width:100%;min-width:0;min-height:36px;padding:7px 9px;background:var(--bg-input);border:1px solid var(--border);border-radius:7px;color:var(--text);font-size:13px;font-family:inherit}
+.setup-input input[type=checkbox]{width:20px;height:20px}
+.setup-more-actions{display:flex;flex-wrap:wrap;gap:6px}
+.setup .btn.pai-quiet{background:transparent;border:1px solid var(--border);color:var(--text-secondary)}
+.setup .btn.pai-quiet:hover{border-color:var(--accent);color:var(--text)}
+.setup .btn.pai-quiet.pai-quiet-danger{color:var(--danger-text,var(--danger));border-color:color-mix(in srgb,var(--danger) 45%,var(--border))}
+.setup-pinned{font-size:12px}
+.setup-result{grid-column:1/-1;font-size:12.5px;line-height:1.5;padding:8px 10px;border-radius:8px;background:var(--bg-secondary);border:1px solid var(--border)}
+.setup-result[hidden]{display:none}
+.setup-result.is-ok{color:var(--success-text,var(--success))}
+.setup-result.is-bad{color:var(--danger-text,var(--danger))}
+.setup-result.is-muted{color:var(--text-muted)}
+.setup-empty,.setup-foot{font-size:12.5px;line-height:1.55;margin:4px 0 12px;max-width:820px}
+.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-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}
+ .setup-where{display:grid;gap:0}
+ .setup-where a{display:flex;align-items:center;gap:6px;min-height:44px;width:fit-content}
+ .setup-input{grid-template-columns:minmax(0,1fr)}
+ .setup-input .btn{justify-content:center}
+ .setup-more-actions .btn{flex:1 1 auto;justify-content:center}
+ .setup-row{padding:12px}
+}
@@ -11,6 +11,23 @@ const AI_KEYS = ['ai_enabled',
@@ -11,6 +11,23 @@ const AI_KEYS = ['ai_enabled',
'ai_max_cost_usd_per_day',
'ai_max_cost_usd_per_day',
'ai_viewers_enabled', 'ai_viewers_max_roster', 'ai_viewers_max_lines_per_min', 'ai_viewers_global_cap_usd_per_day', 'ai_viewers_default_settings_json'];
'ai_viewers_enabled', 'ai_viewers_max_roster', 'ai_viewers_max_lines_per_min', 'ai_viewers_global_cap_usd_per_day', 'ai_viewers_default_settings_json'];
+// Live's AI settings in plain words; the machine key is shown small and grey after each title, never instead of it.
+const AI_KEY_TITLES = {
+ ai_enabled: 'AI on OpenVibe.Live',
+ ai_paste_analysis_enabled: 'Describe new pastes',
+ ai_stream_memory_enabled: 'Remember what happens on streams',
+ ai_stream_capture_interval_sec: 'Seconds between stream snapshots',
+ ai_transcription_enabled: 'Write down what streamers say',
+ ai_timeline_enabled: 'Build a timeline of each stream',
+ ai_max_cost_usd_per_day: 'Most Live may spend on AI in a day (dollars)',
+ ai_viewers_enabled: 'AI chat viewers',
+ ai_viewers_max_roster: 'Most AI viewers in one channel',
+ ai_viewers_max_lines_per_min: 'Most AI viewer messages a minute',
+ ai_viewers_global_cap_usd_per_day: 'Most AI viewers may spend in a day on OpenVibe\'s key (dollars)',
+ ai_viewers_default_settings_json: 'AI viewers\' starting settings (advanced)',
+};
+const aiKeyTitle = (key) => AI_KEY_TITLES[key] || (typeof paiHumanize === 'function' ? paiHumanize(key) : key);
+
// ── The staff room (OpenVibe.Chat /r/staff): one room for all of staff, with Vibe and a shared work queue ──
// ── The staff room (OpenVibe.Chat /r/staff): one room for all of staff, with Vibe and a shared work queue ──
const CHAT_ORIGIN = 'https://openvibe.chat';
const CHAT_ORIGIN = 'https://openvibe.chat';
const TASK_STATE = { queued: 'Waiting', running: 'Working', succeeded: 'Done', failed: 'Stuck', cancelled: 'Cancelled' };
const TASK_STATE = { queued: 'Waiting', running: 'Working', succeeded: 'Done', failed: 'Stuck', cancelled: 'Cancelled' };
@@ -65,18 +82,18 @@ async function loadAi() {
@@ -65,18 +82,18 @@ async function loadAi() {
// Server redacts API keys / money settings for non-owner admins — show locked.
// Server redacts API keys / money settings for non-owner admins — show locked.
if (s.redacted) return `
if (s.redacted) return `
<div class="setting-row" style="flex-direction:column;align-items:stretch">
<div class="setting-row" style="flex-direction:column;align-items:stretch">
- <label><strong>${esc(key)}</strong> <span style="color:var(--muted);font-size:11px;font-weight:600">🔒 Owner only</span><br><small>${esc(s.description||'')}</small></label>
+ <label><strong>${esc(aiKeyTitle(key))}</strong> <code class="pai-mkey">${esc(key)}</code> <span style="color:var(--muted);font-size:11px;font-weight:600">🔒 Owner only</span><br><small>${esc(s.description||'')}</small></label>
<input type="text" value="${esc(s.value)}" disabled data-redacted="1"
<input type="text" value="${esc(s.value)}" disabled data-redacted="1"
style="margin-top:4px;padding:8px 10px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--muted);font-size:13px;opacity:0.65"></div>`;
style="margin-top:4px;padding:8px 10px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--muted);font-size:13px;opacity:0.65"></div>`;
if (s.type === 'boolean') return `
if (s.type === 'boolean') return `
- <div class="setting-row"><label><strong>${esc(key)}</strong><br><small>${esc(s.description||'')}</small></label>
+ <div class="setting-row"><label><strong>${esc(aiKeyTitle(key))}</strong> <code class="pai-mkey">${esc(key)}</code><br><small>${esc(s.description||'')}</small></label>
<input type="checkbox" data-key="${esc(key)}" data-type="boolean" ${s.value==='true'?'checked':''} style="width:18px;height:18px;cursor:pointer"></div>`;
<input type="checkbox" data-key="${esc(key)}" data-type="boolean" ${s.value==='true'?'checked':''} style="width:18px;height:18px;cursor:pointer"></div>`;
if (s.type === 'json' || /_json$/.test(key)) return `<div class="setting-row" style="flex-direction:column;align-items:stretch">
if (s.type === 'json' || /_json$/.test(key)) return `<div class="setting-row" style="flex-direction:column;align-items:stretch">
- <label><strong>${esc(key)}</strong><br><small>${esc(s.description||'')}</small></label>
+ <label><strong>${esc(aiKeyTitle(key))}</strong> <code class="pai-mkey">${esc(key)}</code><br><small>${esc(s.description||'')}</small></label>
<textarea data-key="${esc(key)}" data-type="json" rows="4" spellcheck="false" style="margin-top:4px;padding:8px 10px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:12px;font-family:ui-monospace,monospace">${esc((() => { try { return JSON.stringify(JSON.parse(s.value), null, 2); } catch { return s.value; } })())}</textarea></div>`;
<textarea data-key="${esc(key)}" data-type="json" rows="4" spellcheck="false" style="margin-top:4px;padding:8px 10px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:12px;font-family:ui-monospace,monospace">${esc((() => { try { return JSON.stringify(JSON.parse(s.value), null, 2); } catch { return s.value; } })())}</textarea></div>`;
const type = s.type === 'number' ? 'number' : (sensitive ? 'password' : 'text');
const type = s.type === 'number' ? 'number' : (sensitive ? 'password' : 'text');
return `<div class="setting-row" style="flex-direction:column;align-items:stretch">
return `<div class="setting-row" style="flex-direction:column;align-items:stretch">
- <label><strong>${esc(key)}</strong><br><small>${esc(s.description||'')}</small></label>
+ <label><strong>${esc(aiKeyTitle(key))}</strong> <code class="pai-mkey">${esc(key)}</code><br><small>${esc(s.description||'')}</small></label>
<input type="${type}" data-key="${esc(key)}" data-type="${esc(s.type||'string')}" value="${esc(s.value)}"
<input type="${type}" data-key="${esc(key)}" data-type="${esc(s.type||'string')}" value="${esc(s.value)}"
style="margin-top:4px;padding:8px 10px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px"></div>`;
style="margin-top:4px;padding:8px 10px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px"></div>`;
};
};
@@ -101,7 +118,7 @@ async function loadAi() {
@@ -101,7 +118,7 @@ async function loadAi() {
}
}
c.innerHTML = `
c.innerHTML = `
<div style="max-width:760px">
<div style="max-width:760px">
- <p class="muted" style="margin-bottom:12px">AI analysis for pastes, live-stream memories and AI viewers. Every call is a run on OpenVibe.AI, which holds the provider keys, models and prices (its console at ai.openvibe.network/console). Nothing runs until <strong>ai_enabled</strong> is on. Set <strong>ai_max_cost_usd_per_day</strong> to cap Live's daily spend.</p>
+ <p class="muted" style="margin-bottom:12px">What OpenVibe.Live does with AI: describing pastes, remembering streams and the AI chat viewers. Every request goes through OpenVibe.AI, which holds the provider keys, models and prices (its console is at ai.openvibe.services/console). Nothing runs until <strong>${esc(aiKeyTitle('ai_enabled'))}</strong> is on, and <strong>${esc(aiKeyTitle('ai_max_cost_usd_per_day'))}</strong> caps Live's spending.</p>
<fieldset style="border:1px solid var(--border);border-radius:8px;padding:12px 16px;margin-bottom:16px">
<fieldset style="border:1px solid var(--border);border-radius:8px;padding:12px 16px;margin-bottom:16px">
<legend style="padding:0 8px;font-weight:700">Estimated Cost</legend>
<legend style="padding:0 8px;font-weight:700">Estimated Cost</legend>
${costHtml}
${costHtml}
@@ -153,7 +170,7 @@ async function loadAiViewersFleet() {
@@ -153,7 +170,7 @@ async function loadAiViewersFleet() {
<span>Default engine: <b>${esc(d.engine_default)}</b></span>
<span>Default engine: <b>${esc(d.engine_default)}</b></span>
<span>Kill switch: <b style="color:${d.kill_switch ? '#f87171' : '#4ade80'}">${d.kill_switch ? 'ENGAGED (all channels silent)' : 'off'}</b></span>
<span>Kill switch: <b style="color:${d.kill_switch ? '#f87171' : '#4ade80'}">${d.kill_switch ? 'ENGAGED (all channels silent)' : 'off'}</b></span>
<span>Shared-key viewer spend today: <b>${money(d.global_spend_today_usd)}</b></span>
<span>Shared-key viewer spend today: <b>${money(d.global_spend_today_usd)}</b></span>
- <span class="muted" style="font-size:12px">The kill switch is ai_viewers_enabled in Configuration below.</span>
+ <span class="muted" style="font-size:12px">The switch that silences them all is ${esc(aiKeyTitle('ai_viewers_enabled'))} in Configuration below.</span>
</div>
</div>
${d.running.length ? `<table style="width:100%;font-size:13px;border-collapse:collapse;margin-bottom:10px"><tr><th style="text-align:left">Running now</th><th>Engine</th><th>Stream</th><th>Bots</th><th>Mode</th><th>Lines</th><th>Passes</th><th style="text-align:right">Cost</th><th></th></tr>
${d.running.length ? `<table style="width:100%;font-size:13px;border-collapse:collapse;margin-bottom:10px"><tr><th style="text-align:left">Running now</th><th>Engine</th><th>Stream</th><th>Bots</th><th>Mode</th><th>Lines</th><th>Passes</th><th style="text-align:right">Cost</th><th></th></tr>
${d.running.map(r=>`<tr><td>${esc(r.username||('#'+r.user_id))}</td><td>${esc(r.engine)}</td><td>${r.stream_id}</td><td>${r.bots}</td><td>${esc(r.paused ? 'paused' : (r.mode||''))}</td><td>${r.stats ? r.stats.lines : '—'}</td><td>${r.stats ? r.stats.ticks : '—'}</td><td style="text-align:right">${r.stats ? money(r.stats.cost) : '—'}</td>
${d.running.map(r=>`<tr><td>${esc(r.username||('#'+r.user_id))}</td><td>${esc(r.engine)}</td><td>${r.stream_id}</td><td>${r.bots}</td><td>${esc(r.paused ? 'paused' : (r.mode||''))}</td><td>${r.stats ? r.stats.lines : '—'}</td><td>${r.stats ? r.stats.ticks : '—'}</td><td style="text-align:right">${r.stats ? money(r.stats.cost) : '—'}</td>
@@ -562,21 +579,33 @@ async function testTTSVoice() {
@@ -562,21 +579,33 @@ async function testTTSVoice() {
// registry, in one long stacked page (no inner tabs, nothing display:none) with a sticky in-page index. The server
// registry, in one long stacked page (no inner tabs, nothing display:none) with a sticky in-page index. The server
// proxy is /api/admin/ai/config (server/admin/ai-config-proxy.js); it holds no value, and neither does this page:
// proxy is /api/admin/ai/config (server/admin/ai-config-proxy.js); it holds no value, and neither does this page:
// a secret is only ever written, never rendered. Network's admin helpers (api, esc, toast, timeAgo) are above.
// a secret is only ever written, never rendered. Network's admin helpers (api, esc, toast, timeAgo) are above.
+// The words are the clear admin panel's (OWNER-SETUP-AND-ADMIN-CLARITY-DESIGN, 2026-11-07, section 3): a title in
+// plain words with the machine key small and grey after it, every choice in words, Advanced settings in a counted
+// fold, status as a sentence. glossaryLink() (admin/glossary.js) is optional here, so the file also loads alone.
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
const PAI_NAMES = {
const PAI_NAMES = {
network: 'OpenVibe.Network', ai: 'OpenVibe.AI', billing: 'OpenVibe.Billing', services: 'OpenVibe.Services', watch: 'OpenVibe.Watch',
network: 'OpenVibe.Network', ai: 'OpenVibe.AI', billing: 'OpenVibe.Billing', services: 'OpenVibe.Services', watch: 'OpenVibe.Watch',
bench: 'OpenVibe.Bench', report: 'OpenVibe.Report', host: 'OpenVibe.Host', codes: 'OpenVibe.Codes',
bench: 'OpenVibe.Bench', report: 'OpenVibe.Report', host: 'OpenVibe.Host', codes: 'OpenVibe.Codes',
- actor: 'OpenVibe.Actor',
+ actor: 'OpenVibe.Actor', chat: 'OpenVibe.Chat', connect: 'OpenVibe Connect', deals: 'OpenVibe.Deals', work: 'OpenVibe.Work',
+ sources: 'OpenVibe.Sources', design: 'OpenVibe.Design', domains: 'OpenVibe.Domains', media: 'OpenVibe.Media',
};
};
+// The services whose settings are AI settings: their cards are on this tab. Every other service's owner inputs are
+// on the Setup tab; the "Where keys are kept" list covers them all.
+const PAI_AI_SERVICES = ['ai', 'billing', 'services', 'watch', 'bench', 'report', 'host', 'codes', 'actor'];
const PAI_ACCESS = ['OWN_DEPLOYMENT', 'AUTHORIZED_WHOLESALE', 'CUSTOMER_BYOK', 'RESTRICTED', 'UNVERIFIED'];
const PAI_ACCESS = ['OWN_DEPLOYMENT', 'AUTHORIZED_WHOLESALE', 'CUSTOMER_BYOK', 'RESTRICTED', 'UNVERIFIED'];
-const PAI_BILLING_LABEL = { metered: 'Metered', subscription: 'Subscription', payg: 'Pay as you go', free: 'Free', byok: 'Customer BYOK' };
+const PAI_BILLING_LABEL = {
+ metered: 'Spends Credits per request', subscription: 'A plan with a monthly fee shared by everyone',
+ payg: 'A prepaid balance at the provider', free: 'Free', byok: 'The person\'s own key pays',
+};
const PAI_ACCESS_LABEL = {
const PAI_ACCESS_LABEL = {
- OWN_DEPLOYMENT: 'Own deployment', AUTHORIZED_WHOLESALE: 'Authorized wholesale', CUSTOMER_BYOK: 'Customer BYOK',
- RESTRICTED: 'Restricted', UNVERIFIED: 'Unverified',
+ OWN_DEPLOYMENT: 'Runs on our own machine: no provider terms', AUTHORIZED_WHOLESALE: 'Our agreement allows reselling it',
+ CUSTOMER_BYOK: 'Only with the person\'s own key', RESTRICTED: 'Do not sell: their terms forbid it',
+ UNVERIFIED: 'Not checked yet: treated as Do not sell',
};
};
// An integer's x-unit, as the owner form labels it. A unit this page does not know is left off so the
// An integer's x-unit, as the owner form labels it. A unit this page does not know is left off so the
// input is a plain number: the page then works with a newer openvibe-shared before its pin moves.
// input is a plain number: the page then works with a newer openvibe-shared before its pin moves.
const PAI_UNIT_LABEL = { ms: 'ms', seconds: 's', bps: 'bps' };
const PAI_UNIT_LABEL = { ms: 'ms', seconds: 's', bps: 'bps' };
+const PAI_UNIT_WORDS = { ms: 'milliseconds', seconds: 'seconds', bps: 'bits per second' };
const PAI = {
const PAI = {
providers: [], services: [], namespaces: [], warnings: [], vault: {}, serviceState: {}, loading: false, addProviderError: null,
providers: [], services: [], namespaces: [], warnings: [], vault: {}, serviceState: {}, loading: false, addProviderError: null,
};
};
@@ -589,6 +618,17 @@ function paiLabel(service) {
@@ -589,6 +618,17 @@ function paiLabel(service) {
return PAI_NAMES[service] || ('OpenVibe.' + String(service || '').replace(/^./, (c) => c.toUpperCase()));
return PAI_NAMES[service] || ('OpenVibe.' + String(service || '').replace(/^./, (c) => c.toUpperCase()));
}
}
+/** A machine key in words when a schema gives no title: "min_samples" → "Min samples", "vast.key" → "Vast key". */
+function paiHumanize(key) {
+ const words = String(key || '').replace(/([a-z])([A-Z])/g, '$1 $2').split(/[._\-\s]+/).filter(Boolean).join(' ').toLowerCase();
+ return words ? words.charAt(0).toUpperCase() + words.slice(1) : String(key || '');
+}
+
+/** "What does this mean?" when the glossary is on the page (admin/glossary.js), nothing otherwise. */
+function paiWhat(id, label) {
+ return typeof glossaryLink === 'function' ? glossaryLink(id, label) : '';
+}
+
function paiApi(path, opts = {}) {
function paiApi(path, opts = {}) {
return api('/api/admin/ai/config' + path, opts).catch((err) => {
return api('/api/admin/ai/config' + path, opts).catch((err) => {
if (err && err.status === 403 && err.body && err.body.code === 'config.fresh_required') {
if (err && err.status === 403 && err.body && err.body.code === 'config.fresh_required') {
@@ -602,7 +642,7 @@ function paiApi(path, opts = {}) {
@@ -602,7 +642,7 @@ function paiApi(path, opts = {}) {
function paiShowFresh(url) {
function paiShowFresh(url) {
const el = document.getElementById('pai-fresh');
const el = document.getElementById('pai-fresh');
if (!el) return;
if (!el) return;
- const link = el.querySelector('a');
+ const link = el.querySelector('a.btn');
if (link && url) link.href = url;
if (link && url) link.href = url;
el.hidden = false;
el.hidden = false;
}
}
@@ -645,16 +685,22 @@ function paiWhen(iso) {
@@ -645,16 +685,22 @@ function paiWhen(iso) {
try { return timeAgo(iso); } catch { return ''; }
try { return timeAgo(iso); } catch { return ''; }
}
}
-function paiMarker(value) {
- return value && typeof value === 'object' && value.redacted === true ? value : null;
-}
-
function paiFold(items, render) {
function paiFold(items, render) {
const head = items.slice(0, 8).map(render).join('');
const head = items.slice(0, 8).map(render).join('');
if (items.length <= 8) return '<div class="pai-rows">' + head + '</div>';
if (items.length <= 8) return '<div class="pai-rows">' + head + '</div>';
return '<div class="pai-rows">' + head + '</div><details class="pai-more"><summary>More (' + (items.length - 8) + ')</summary><div class="pai-rows">' + items.slice(8).map(render).join('') + '</div></details>';
return '<div class="pai-rows">' + head + '</div><details class="pai-more"><summary>More (' + (items.length - 8) + ')</summary><div class="pai-rows">' + items.slice(8).map(render).join('') + '</div></details>';
}
}
+/**
+ * Whether a key is a secret: the summary's classification (every schema property from openvibe-shared 4.125.0), or,
+ * for an unset key of an older service, a bound or writeOnly key, or a key/token/secret/password name.
+ */
+function paiIsSecret(n, key, prop) {
+ const cls = n.classification ? n.classification[key] : undefined;
+ if (cls) return cls === 'secret';
+ return !!(prop && (typeof prop['x-bind'] === 'string' || prop.writeOnly === true)) || /(^|[._-])(api_?key|key|token|secret|password)$/i.test(key);
+}
+
async function loadPlatformAi() {
async function loadPlatformAi() {
const root = document.getElementById('platform-ai');
const root = document.getElementById('platform-ai');
if (!root) return;
if (!root) return;
@@ -677,7 +723,7 @@ async function loadPlatformAi() {
@@ -677,7 +723,7 @@ async function loadPlatformAi() {
PAI.serviceState = {};
PAI.serviceState = {};
for (const answer of answers) {
for (const answer of answers) {
if (answer.error) {
if (answer.error) {
- PAI.warnings.push({ service: answer.service, namespace: null, reason: 'unavailable', detail: paiFriendly(answer.service, answer.error) });
+ // Not a warning: a service that has not mounted its settings routes yet is said in plain words below.
PAI.vault[answer.service] = { sealed: null, kek: null };
PAI.vault[answer.service] = { sealed: null, kek: null };
PAI.serviceState[answer.service] = { answered: false, sealed: null, kek: null };
PAI.serviceState[answer.service] = { answered: false, sealed: null, kek: null };
continue;
continue;
@@ -685,7 +731,7 @@ async function loadPlatformAi() {
@@ -685,7 +731,7 @@ async function loadPlatformAi() {
PAI.namespaces.push(...(answer.namespaces || []));
PAI.namespaces.push(...(answer.namespaces || []));
PAI.warnings.push(...(answer.warnings || []));
PAI.warnings.push(...(answer.warnings || []));
PAI.vault[answer.service] = answer.vault || { sealed: null, kek: null };
PAI.vault[answer.service] = answer.vault || { sealed: null, kek: null };
- PAI.serviceState[answer.service] = { answered: true, sealed: answer.vault ? answer.vault.sealed : null, kek: answer.vault ? answer.vault.kek : null };
+ PAI.serviceState[answer.service] = { answered: true, published: (answer.namespaces || []).length, sealed: answer.vault ? answer.vault.sealed : null, kek: answer.vault ? answer.vault.kek : null };
}
}
paiRender(root);
paiRender(root);
} catch (err) {
} catch (err) {
@@ -697,32 +743,43 @@ async function loadPlatformAi() {
@@ -697,32 +743,43 @@ async function loadPlatformAi() {
function paiShell(inner) {
function paiShell(inner) {
return '<div class="pai-head"><h2><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i> Platform AI</h2>'
return '<div class="pai-head"><h2><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i> Platform AI</h2>'
- + '<p class="muted">Every service\'s AI settings in one place. Keys are sealed and write-only: once saved, nothing here can show one again. Money and key changes ask you to confirm it is you.</p></div>'
- + '<div id="pai-fresh" class="pai-fresh" hidden><i class="fa-solid fa-lock" aria-hidden="true"></i> <span>Confirm it\'s you to change keys and money settings.</span> <a class="btn btn-primary" href="/login?reauth=1&return=%2Fadmin%23ai">Sign in again</a></div>'
+ + '<p class="muted">Everything about OpenVibe\'s AI in one place: the providers and their keys, what the Exchange offers, the money switches and other services\' AI settings. Keys are locked and write-only, and a key or money change asks you to confirm it\'s you.</p></div>'
+ + '<div id="pai-fresh" class="pai-fresh" hidden><i class="fa-solid fa-lock" aria-hidden="true"></i> <span>Confirm it\'s you to change keys and money settings.</span> <a class="btn btn-primary" href="/login?reauth=1&return=%2Fadmin%23ai">Sign in again</a> ' + paiWhat('fresh-sign-in') + '</div>'
+ inner;
+ inner;
}
}
+// A namespace made only of owner inputs for something other than AI (publishing tokens, sign-in apps, data sources,
+// chat apps…) is the Setup tab's, not this one's: Codes' publishing tokens read as "AI settings" here (2026-11-07).
+const PAI_NOT_AI_GROUPS = new Set(['chat-apps', 'sign-in-apps', 'data-sources', 'design-publishing', 'domains', 'legal', 'voices']);
+function paiSetupOnly(n) {
+ const props = n && n.schema && n.schema.properties ? Object.values(n.schema.properties) : [];
+ return props.length > 0 && props.every((p) => p && p['x-owner-input'] && PAI_NOT_AI_GROUPS.has(p['x-owner-input'].group));
+}
+const paiAiNamespaces = () => PAI.namespaces.filter((n) => PAI_AI_SERVICES.includes(n.service) && !n.namespace.endsWith('.provider_keys') && !paiSetupOnly(n));
+
function paiRender(root) {
function paiRender(root) {
- const exchange = PAI.namespaces.filter((n) => n.service === 'ai' && !n.namespace.endsWith('.provider_keys'));
- const money = PAI.namespaces.filter((n) => n.service === 'billing');
- const rest = PAI.namespaces.filter((n) => n.service !== 'ai' && n.service !== 'billing' && !n.namespace.endsWith('.provider_keys'));
+ const all = paiAiNamespaces();
+ const exchange = all.filter((n) => n.service === 'ai');
+ const money = all.filter((n) => n.service === 'billing');
+ const rest = all.filter((n) => n.service !== 'ai' && n.service !== 'billing');
const count = (n) => n ? ' <span class="pai-count">' + n + '</span>' : '';
const count = (n) => n ? ' <span class="pai-count">' + n + '</span>' : '';
root.innerHTML = paiShell(
root.innerHTML = paiShell(
'<nav class="pai-index" aria-label="Platform AI sections">'
'<nav class="pai-index" aria-label="Platform AI sections">'
- + '<a href="#pai-providers">Providers and accounts' + count(PAI.providers.length) + '</a>'
- + '<a href="#pai-exchange">AI Exchange' + count(exchange.length) + '</a>'
+ + '<a href="#pai-providers">AI providers' + count(PAI.providers.length) + '</a>'
+ + '<a href="#pai-exchange">What the Exchange offers' + count(exchange.length) + '</a>'
+ '<a href="#pai-money">Money' + count(money.length) + '</a>'
+ '<a href="#pai-money">Money' + count(money.length) + '</a>'
- + '<a href="#pai-services">Services' + count(rest.length) + '</a>'
- + '<a href="#pai-vault">Vault' + count(PAI.services.length) + '</a>'
+ + '<a href="#pai-services">Other services\' AI settings' + count(rest.length) + '</a>'
+ + '<a href="#pai-vault">Where keys are kept' + count(PAI.services.length) + '</a>'
+ '</nav>'
+ '</nav>'
+ '<div id="pai-warnings"></div>'
+ '<div id="pai-warnings"></div>'
- + '<section id="pai-providers" class="pai-section"><div class="pai-section-head"><h3>Providers and accounts</h3><p class="muted">The providers OpenVibe.AI may call, their billing profile and their key. A subscription account runs on a shared pool; a metered one spends Credits.</p></div>'
+ + '<section id="pai-providers" class="pai-section"><div class="pai-section-head"><h3>AI providers</h3><p class="muted">The companies whose models OpenVibe can use, and the key for each. Add one to make its models available; a provider with no working key is skipped.</p>'
+ + '<p class="pai-words muted">Words used here: ' + [paiWhat('resale', 'reselling'), paiWhat('byok', 'the person\'s own key'), paiWhat('health', 'paused'), paiWhat('credits', 'Credits')].filter(Boolean).join(' · ') + '</p></div>'
+ '<div class="pai-toolbar"><button type="button" class="btn btn-primary" id="pai-add-provider"><i class="fa-solid fa-plus" aria-hidden="true"></i> Add provider</button></div>'
+ '<div class="pai-toolbar"><button type="button" class="btn btn-primary" id="pai-add-provider"><i class="fa-solid fa-plus" aria-hidden="true"></i> Add provider</button></div>'
+ '<div id="pai-providers-list"></div></section>'
+ '<div id="pai-providers-list"></div></section>'
- + '<section id="pai-exchange" class="pai-section"><h3>AI Exchange</h3><p class="muted">What OpenVibe.AI routes, which models it offers and what it may spend.</p><div id="pai-cards-exchange" data-pai-cards></div></section>'
- + '<section id="pai-money" class="pai-section"><h3>Money</h3><p class="muted">Credits, top-ups and the sandbox. Every money switch says exactly what it turns on.</p><div id="pai-cards-money" data-pai-cards></div></section>'
- + '<section id="pai-services" class="pai-section"><h3>Services</h3><p class="muted">The settings of every other service, drawn from each namespace\'s schema.</p><div id="pai-cards-services" data-pai-cards></div></section>'
- + '<section id="pai-vault" class="pai-section"><h3>Vault</h3><p class="muted">Secrets are sealed with the service\'s own key-encryption key (KEK); without one the store keeps its pre-D11 plaintext behaviour and says so.</p><div id="pai-vault-list"></div></section>'
+ + '<section id="pai-exchange" class="pai-section"><div class="pai-section-head"><h3>What the Exchange offers</h3><p class="muted">Which models people can pick on openvibe.exchange, the free tier, and the spending limits that protect your balance.</p></div><div id="pai-cards-exchange" data-pai-cards></div></section>'
+ + '<section id="pai-money" class="pai-section"><div class="pai-section-head"><h3>Money</h3><p class="muted">Credits are what people spend on AI; these switches decide whether people can buy them, top up and pay sellers. Each switch says what it turns on before you change it. ' + paiWhat('credits') + '</p></div><div id="pai-cards-money" data-pai-cards></div></section>'
+ + '<section id="pai-services" class="pai-section"><div class="pai-section-head"><h3>Other services\' AI settings</h3><p class="muted">AI settings that live in other parts of OpenVibe (Codes, Actor, Bench…), one card per service. Their other keys (publishing, sign-in apps, data sources) are on the <a href="/admin/setup">Setup</a> tab.</p></div><div id="pai-cards-services" data-pai-cards></div></section>'
+ + '<section id="pai-vault" class="pai-section"><div class="pai-section-head"><h3>Where keys are kept</h3><p class="muted">Every key you enter is locked with that service\'s own lock key and can never be read back, not even by you. A service without its lock key stores keys unlocked and says so here: fix that first. ' + paiWhat('lock-key') + '</p></div><div id="pai-vault-list"></div></section>'
);
);
paiRenderWarnings();
paiRenderWarnings();
paiRenderProviders();
paiRenderProviders();
@@ -751,11 +808,12 @@ function paiRenderWarnings() {
@@ -751,11 +808,12 @@ function paiRenderWarnings() {
const box = document.getElementById('pai-warnings');
const box = document.getElementById('pai-warnings');
if (!box) return;
if (!box) return;
if (!PAI.warnings.length) { box.innerHTML = ''; return; }
if (!PAI.warnings.length) { box.innerHTML = ''; return; }
- box.innerHTML = '<div class="pai-warning"><strong>Some answers were dropped.</strong> The service answered with something that is not a settings summary, so it is not shown.'
+ box.innerHTML = '<div class="pai-warning"><strong>Some settings could not be shown.</strong> These services answered with something that is not a settings summary, so their cards are left out until they answer correctly.'
+ PAI.warnings.map((w) => '<div class="pai-warning-row">' + esc(paiLabel(w.service)) + (w.namespace ? ' · <code>' + esc(w.namespace) + '</code>' : '') + ' — ' + esc(w.reason === 'invalid-summary' ? 'not a settings summary' : (w.reason || 'not available')) + '</div>').join('')
+ PAI.warnings.map((w) => '<div class="pai-warning-row">' + esc(paiLabel(w.service)) + (w.namespace ? ' · <code>' + esc(w.namespace) + '</code>' : '') + ' — ' + esc(w.reason === 'invalid-summary' ? 'not a settings summary' : (w.reason || 'not available')) + '</div>').join('')
+ '</div>';
+ '</div>';
}
}
+/** Key: "Entered here, locked" / "From the server's settings file (NAME)" / "Not set". */
function paiKeyState(p) {
function paiKeyState(p) {
const ref = String(p.secret_ref || '');
const ref = String(p.secret_ref || '');
const vault = ref.startsWith('vault:');
const vault = ref.startsWith('vault:');
@@ -763,27 +821,48 @@ function paiKeyState(p) {
@@ -763,27 +821,48 @@ function paiKeyState(p) {
const hint = p.key_hint || p.hint || p.secret_hint || '';
const hint = p.key_hint || p.hint || p.secret_hint || '';
const bound = p.bound_to || paiHost(p.base_url);
const bound = p.bound_to || paiHost(p.base_url);
const required = p.auth_mode !== 'none' && p.credentials !== 'not_required';
const required = p.auth_mode !== 'none' && p.credentials !== 'not_required';
- if (!required) return { tone: 'muted', text: 'Not required', required: false };
- if (vault && p.credentials !== 'missing') return { tone: 'ok', text: 'Sealed' + (hint ? ' ••••' + esc(hint) : '') + ' · bound to ' + esc(paiHost(bound)), required: true };
- if (env) return { tone: 'muted', text: 'From env ' + esc(ref.slice(4)), env: ref.slice(4), required: true };
- if (p.credentials === 'missing' || !ref) return { tone: 'bad', text: 'No key', required: true };
- return { tone: 'ok', text: 'Sealed · bound to ' + esc(paiHost(bound)), required: true };
+ if (!required) return { tone: 'muted', text: 'Key: not needed', required: false };
+ if (vault && p.credentials !== 'missing') return { tone: 'ok', text: 'Key: Entered here, locked' + (hint ? ' · ••••' + esc(hint) : '') + ' · only sent to ' + esc(paiHost(bound)), required: true };
+ if (env) return { tone: 'muted', text: 'Key: From the server\'s settings file (' + esc(ref.slice(4)) + ')', env: ref.slice(4), required: true };
+ if (p.credentials === 'missing' || !ref) return { tone: 'bad', text: 'Key: Not set', required: true };
+ return { tone: 'ok', text: 'Key: Entered here, locked · only sent to ' + esc(paiHost(bound)), required: true };
+}
+
+/** Health: "Working" / "Paused after N errors; tries again at HH:MM" / "Testing again" (AI's circuit, in words). */
+function paiHealth(p) {
+ const h = p && p.health;
+ if (!h || !h.state) return null;
+ if (h.state === 'closed') return { tone: 'ok', text: 'Health: Working' };
+ if (h.state === 'half_open') return { tone: 'muted', text: 'Health: Testing again' };
+ if (h.state === 'open') {
+ const n = Number(h.consecutive_failures) || 0;
+ const errors = n === 1 ? '1 error' : n + ' errors';
+ let retry = h.retry_at ? Date.parse(h.retry_at) : NaN;
+ const opened = typeof h.opened_at === 'number' ? h.opened_at : Date.parse(h.opened_at || '');
+ // AI waits a minute after errors, and ten minutes when the provider's balance ran out.
+ if (!Number.isFinite(retry) && Number.isFinite(opened)) retry = opened + (h.exhausted_at ? 10 * 60000 : 60000);
+ const at = Number.isFinite(retry) ? new Date(retry).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : null;
+ return { tone: 'bad', text: 'Health: Paused after ' + errors + (h.exhausted_at ? ' (its balance ran out)' : '') + (at ? '; tries again at ' + at : '; it tries again shortly') };
+ }
+ return null;
}
}
function paiProviderRow(p) {
function paiProviderRow(p) {
const access = PAI_ACCESS.includes(String(p.provider_access || '').toUpperCase()) ? String(p.provider_access).toUpperCase() : 'UNVERIFIED';
const access = PAI_ACCESS.includes(String(p.provider_access || '').toUpperCase()) ? String(p.provider_access).toUpperCase() : 'UNVERIFIED';
- const billing = p.billing_profile === 'subscription' ? 'Subscription · ' + esc(p.pool_key || p.key) : esc(PAI_BILLING_LABEL[p.billing_profile] || PAI_BILLING_LABEL.metered);
+ const billing = p.billing_profile === 'subscription' ? esc(PAI_BILLING_LABEL.subscription) + ' (' + esc(p.pool_key || p.key) + ')' : esc(PAI_BILLING_LABEL[p.billing_profile] || PAI_BILLING_LABEL.metered);
const key = paiKeyState(p);
const key = paiKeyState(p);
+ const health = paiHealth(p);
const sealed = String(p.secret_ref || '').startsWith('vault:') && p.credentials !== 'missing';
const sealed = String(p.secret_ref || '').startsWith('vault:') && p.credentials !== 'missing';
const actions = '<button type="button" class="btn btn-sm pai-quiet" data-act="edit">Edit</button> '
const actions = '<button type="button" class="btn btn-sm pai-quiet" data-act="edit">Edit</button> '
- + (key.required && key.env ? '<button type="button" class="btn btn-sm pai-quiet" data-act="key">Move into the vault</button> ' : '')
- + (key.required ? '<button type="button" class="btn btn-sm pai-quiet" data-act="key">' + (sealed ? 'Replace key' : 'Add key') + '</button> ' : '')
+ + (key.required && key.env ? '<button type="button" class="btn btn-sm pai-quiet" data-act="key" title="Enter the key here so it is locked by OpenVibe.AI instead of read from the server\'s settings file">Enter it here instead</button> ' : '')
+ + (key.required && !key.env ? '<button type="button" class="btn btn-sm pai-quiet" data-act="key">' + (sealed ? 'Replace key' : 'Add key') + '</button> ' : '')
+ (key.required && sealed ? '<button type="button" class="btn btn-sm pai-quiet pai-quiet-danger" data-act="remove-key">Remove key</button>' : '');
+ (key.required && sealed ? '<button type="button" class="btn btn-sm pai-quiet pai-quiet-danger" data-act="remove-key">Remove key</button>' : '');
return '<div class="pai-provider" data-pai-provider="' + esc(p.key) + '">'
return '<div class="pai-provider" data-pai-provider="' + esc(p.key) + '">'
- + '<div class="pai-provider-main"><div class="pai-provider-name">' + esc(p.display_name || p.key) + ' <span class="pai-kind">' + esc(p.kind || '—') + '</span></div>'
+ + '<div class="pai-provider-main"><div class="pai-provider-name">' + esc(p.display_name || p.key) + ' <code class="pai-mkey">' + esc(p.key) + '</code></div>'
+ '<div class="pai-provider-meta"><code>' + esc(paiHost(p.base_url)) + '</code> <span class="pai-access is-' + access.toLowerCase() + '">' + esc(PAI_ACCESS_LABEL[access]) + '</span> <span class="pai-billing">' + billing + '</span></div>'
+ '<div class="pai-provider-meta"><code>' + esc(paiHost(p.base_url)) + '</code> <span class="pai-access is-' + access.toLowerCase() + '">' + esc(PAI_ACCESS_LABEL[access]) + '</span> <span class="pai-billing">' + billing + '</span></div>'
- + '<div class="pai-keystate is-' + key.tone + '">' + key.text + '</div></div>'
- + '<div class="pai-provider-actions"><label class="pai-switch"><input type="checkbox" role="switch" data-pai-enabled data-ov-native ' + (p.status === 'active' ? 'checked' : '') + '><span class="pai-track" aria-hidden="true"><span class="pai-knob"></span></span><span class="pai-switch-text">' + (p.status === 'active' ? 'Enabled' : 'Disabled') + '</span></label>' + actions + '</div></div>';
+ + '<div class="pai-keystate is-' + key.tone + '">' + key.text + '</div>'
+ + (health ? '<div class="pai-keystate is-' + health.tone + '">' + esc(health.text) + '</div>' : '') + '</div>'
+ + '<div class="pai-provider-actions"><label class="pai-switch"><input type="checkbox" role="switch" data-pai-enabled data-ov-native ' + (p.status === 'active' ? 'checked' : '') + '><span class="pai-track" aria-hidden="true"><span class="pai-knob"></span></span><span class="pai-switch-text">' + (p.status === 'active' ? 'In use' : 'Not used') + '</span></label>' + actions + '</div></div>';
}
}
function paiRenderProviders() {
function paiRenderProviders() {
@@ -794,13 +873,13 @@ function paiRenderProviders() {
@@ -794,13 +873,13 @@ function paiRenderProviders() {
return;
return;
}
}
if (!PAI.providers.length) {
if (!PAI.providers.length) {
- box.innerHTML = '<p class="muted">No provider is configured yet. Add one to let OpenVibe.AI answer.</p>';
+ box.innerHTML = '<p class="muted">No providers yet. Add DeepSeek to start: press Add provider above.</p>';
return;
return;
}
}
const subscription = PAI.providers.filter((p) => p.billing_profile === 'subscription');
const subscription = PAI.providers.filter((p) => p.billing_profile === 'subscription');
const other = PAI.providers.filter((p) => p.billing_profile !== 'subscription');
const other = PAI.providers.filter((p) => p.billing_profile !== 'subscription');
box.innerHTML = paiFold(other, paiProviderRow)
box.innerHTML = paiFold(other, paiProviderRow)
- + (subscription.length ? '<h4 class="pai-subhead">Subscription accounts (Claude, Codex, …)</h4>' + paiFold(subscription, paiProviderRow) : '');
+ + (subscription.length ? '<h4 class="pai-subhead">On a plan with a monthly fee (Claude, Codex, …)</h4>' + paiFold(subscription, paiProviderRow) : '');
}
}
function paiProviderClick(ev) {
function paiProviderClick(ev) {
@@ -826,12 +905,12 @@ async function paiProviderSwitch(ev) {
@@ -826,12 +905,12 @@ async function paiProviderSwitch(ev) {
await paiApi('/providers/' + encodeURIComponent(provider.key), { method: 'PATCH', body: { status: input.checked ? 'active' : 'disabled' } });
await paiApi('/providers/' + encodeURIComponent(provider.key), { method: 'PATCH', body: { status: input.checked ? 'active' : 'disabled' } });
provider.status = input.checked ? 'active' : 'disabled';
provider.status = input.checked ? 'active' : 'disabled';
const label = input.closest('.pai-switch').querySelector('.pai-switch-text');
const label = input.closest('.pai-switch').querySelector('.pai-switch-text');
- if (label) label.textContent = input.checked ? 'Enabled' : 'Disabled';
- toast(provider.display_name + (input.checked ? ' enabled' : ' disabled'), 'success');
+ if (label) label.textContent = input.checked ? 'In use' : 'Not used';
+ toast((provider.display_name || provider.key) + (input.checked ? ' is in use' : ' is no longer used'), 'success');
} catch (err) {
} catch (err) {
input.checked = !input.checked;
input.checked = !input.checked;
const label = input.closest('.pai-switch').querySelector('.pai-switch-text');
const label = input.closest('.pai-switch').querySelector('.pai-switch-text');
- if (label) label.textContent = input.checked ? 'Enabled' : 'Disabled';
+ if (label) label.textContent = input.checked ? 'In use' : 'Not used';
if (!err.freshRequired) toast(paiFriendly('ai', err), 'error');
if (!err.freshRequired) toast(paiFriendly('ai', err), 'error');
} finally {
} finally {
input.disabled = false;
input.disabled = false;
@@ -848,35 +927,35 @@ function paiProviderDialog(provider) {
@@ -848,35 +927,35 @@ function paiProviderDialog(provider) {
dlg.className = 'pai-dialog';
dlg.className = 'pai-dialog';
const options = (items, current) => items.map((item) => '<option value="' + esc(item.value) + '"' + (item.value === current ? ' selected' : '') + ' data-desc="' + esc(item.desc || '') + '" data-state="' + (item.state || 'off') + '">' + esc(item.label) + '</option>').join('');
const options = (items, current) => items.map((item) => '<option value="' + esc(item.value) + '"' + (item.value === current ? ' selected' : '') + ' data-desc="' + esc(item.desc || '') + '" data-state="' + (item.state || 'off') + '">' + esc(item.label) + '</option>').join('');
const kindOptions = [
const kindOptions = [
- { value: 'openai', label: 'OpenAI-compatible', desc: 'Chat/completions with a bearer key', state: 'ok' },
- { value: 'anthropic', label: 'Anthropic Messages', desc: 'Claude\'s own messages API', state: 'ok' },
- { value: 'http', label: 'Plain HTTP seam', desc: 'A custom endpoint that speaks JSON', state: 'off' },
- { value: 'stub', label: 'Deterministic stub', desc: 'Synthetic answers for tests', state: 'off' },
- { value: 'whisper', label: 'whisper.cpp', desc: 'Local speech-to-text', state: 'off' },
+ { value: 'openai', label: 'OpenAI-compatible', desc: 'Most providers: the chat API OpenAI made, with a key', state: 'ok' },
+ { value: 'anthropic', label: 'Anthropic (Claude)', desc: 'Claude\'s own messages API', state: 'ok' },
+ { value: 'http', label: 'Our own HTTP endpoint', desc: 'A custom address that speaks JSON', state: 'off' },
+ { value: 'stub', label: 'Practice answers', desc: 'For testing only: canned answers, no provider', state: 'off' },
+ { value: 'whisper', label: 'whisper.cpp (speech to text)', desc: 'Speech to text on our own machine', state: 'off' },
];
];
const billingOptions = [
const billingOptions = [
- { value: 'metered', label: 'Metered', desc: 'Every request spends Credits', state: 'ok' },
- { value: 'subscription', label: 'Subscription', desc: 'Runs on a shared plan pool', state: 'ok' },
- { value: 'payg', label: 'Pay as you go', desc: 'A prepaid provider balance', state: 'off' },
- { value: 'free', label: 'Free', desc: 'No provider charge', state: 'off' },
- { value: 'byok', label: 'Customer BYOK', desc: 'A customer\'s own key pays the provider', state: 'new' },
+ { value: 'metered', label: PAI_BILLING_LABEL.metered, desc: 'Each request takes Credits from the person who asked', state: 'ok' },
+ { value: 'subscription', label: PAI_BILLING_LABEL.subscription, desc: 'Name the plan below; requests share its allowance', state: 'ok' },
+ { value: 'payg', label: PAI_BILLING_LABEL.payg, desc: 'You top it up on the provider\'s own site', state: 'off' },
+ { value: 'free', label: PAI_BILLING_LABEL.free, desc: 'The provider charges nothing', state: 'off' },
+ { value: 'byok', label: PAI_BILLING_LABEL.byok, desc: 'Each person enters their own key and pays the provider', state: 'new' },
];
];
dlg.innerHTML = '<form method="dialog" class="pai-form">'
dlg.innerHTML = '<form method="dialog" class="pai-form">'
+ '<h3>' + (editing ? 'Edit provider' : 'Add provider') + '</h3>'
+ '<h3>' + (editing ? 'Edit provider' : 'Add provider') + '</h3>'
- + '<div class="ov-field"><label for="pai-p-key">Key</label><input id="pai-p-key" name="key" required pattern="[a-z][a-z0-9_-]*([.][a-z0-9_-]+)*" ' + (editing ? 'readonly' : '') + ' value="' + esc(p.key || '') + '"><p class="ov-field-hint">Lowercase, dotted or dashed: deepseek, claude, codex. It cannot be renamed.</p></div>'
- + '<div class="ov-field"><label for="pai-p-name">Display name</label><input id="pai-p-name" name="display_name" value="' + esc(p.display_name || '') + '"></div>'
- + '<div class="ov-field"><label for="pai-p-kind">Kind</label><select id="pai-p-kind" name="kind">'
+ + '<div class="ov-field"><label for="pai-p-key">Short name</label><input id="pai-p-key" name="key" required pattern="[a-z][a-z0-9_-]*([.][a-z0-9_-]+)*" ' + (editing ? 'readonly' : '') + ' value="' + esc(p.key || '') + '"><p class="ov-field-hint">Lowercase, with dots or dashes: deepseek, claude, codex. It cannot be renamed later.</p></div>'
+ + '<div class="ov-field"><label for="pai-p-name">Name people see</label><input id="pai-p-name" name="display_name" value="' + esc(p.display_name || '') + '"></div>'
+ + '<div class="ov-field"><label for="pai-p-kind">How OpenVibe talks to it</label><select id="pai-p-kind" name="kind">'
+ options(kindOptions, kind)
+ options(kindOptions, kind)
+ '</select></div>'
+ '</select></div>'
- + '<div class="ov-field"><label for="pai-p-url">Base URL</label><input id="pai-p-url" name="base_url" type="url" placeholder="https://api.example.com" value="' + esc(p.base_url || '') + '"><p class="ov-field-hint">A sealed key is bound to this address: changing it means entering the key again.</p></div>'
- + '<div class="ov-field"><label for="pai-p-access">Provider access</label><select id="pai-p-access" name="provider_access" data-ov-title="Provider access">'
- + PAI_ACCESS.map((a) => '<option value="' + a + '"' + (a === access ? ' selected' : '') + ' data-badge="' + esc(PAI_ACCESS_LABEL[a]) + '" data-desc="' + esc(paiAccessDesc(a)) + '" data-state="' + (a === 'OWN_DEPLOYMENT' || a === 'AUTHORIZED_WHOLESALE' ? 'ok' : a === 'RESTRICTED' ? 'warn' : a === 'UNVERIFIED' ? 'off' : 'new') + '">' + esc(PAI_ACCESS_LABEL[a]) + '</option>').join('')
- + '</select></div>'
- + '<div class="pai-form-row"><div class="ov-field"><label for="pai-p-billing">Billing profile</label><select id="pai-p-billing" name="billing_profile">'
+ + '<div class="ov-field"><label for="pai-p-url">Address (base URL)</label><input id="pai-p-url" name="base_url" type="url" placeholder="https://api.example.com" value="' + esc(p.base_url || '') + '"><p class="ov-field-hint">The key is only ever sent to this address. Changing it means entering the key again.</p></div>'
+ + '<div class="ov-field"><label for="pai-p-access">May OpenVibe sell its use?</label><select id="pai-p-access" name="provider_access" data-ov-title="May OpenVibe sell its use?">'
+ + PAI_ACCESS.map((a) => '<option value="' + a + '"' + (a === access ? ' selected' : '') + ' data-desc="' + esc(paiAccessDesc(a)) + '" data-state="' + (a === 'OWN_DEPLOYMENT' || a === 'AUTHORIZED_WHOLESALE' ? 'ok' : a === 'RESTRICTED' ? 'warn' : a === 'UNVERIFIED' ? 'off' : 'new') + '">' + esc(PAI_ACCESS_LABEL[a]) + '</option>').join('')
+ + '</select><p class="ov-field-hint">Check the provider\'s terms. ' + paiWhat('resale') + '</p></div>'
+ + '<div class="pai-form-row"><div class="ov-field"><label for="pai-p-billing">Who pays</label><select id="pai-p-billing" name="billing_profile">'
+ options(billingOptions, billing)
+ options(billingOptions, billing)
- + '</select></div><div class="ov-field"><label for="pai-p-pool">Pool</label><input id="pai-p-pool" name="pool_key" placeholder="claude-max" value="' + esc(p.pool_key || '') + '"><p class="ov-field-hint">Subscription pools only; a name, never a secret.</p></div></div>'
- + '<div class="pai-form-row"><div class="ov-field"><label for="pai-p-priority">Priority</label><input id="pai-p-priority" name="priority" type="number" step="1" value="' + esc(p.priority == null ? 100 : p.priority) + '"></div>'
- + '<div class="ov-field"><label for="pai-p-model">Default model</label><input id="pai-p-model" name="default_model" value="' + esc(p.default_model || '') + '"></div></div>'
+ + '</select></div><div class="ov-field"><label for="pai-p-pool">Shared plan name</label><input id="pai-p-pool" name="pool_key" placeholder="claude-max" value="' + esc(p.pool_key || '') + '"><p class="ov-field-hint">Only for a plan with a monthly fee; a name, never a key.</p></div></div>'
+ + '<div class="pai-form-row"><div class="ov-field"><label for="pai-p-priority">Order</label><input id="pai-p-priority" name="priority" type="number" step="1" value="' + esc(p.priority == null ? 100 : p.priority) + '"><p class="ov-field-hint">A lower number is tried first.</p></div>'
+ + '<div class="ov-field"><label for="pai-p-model">Model used when none is named</label><input id="pai-p-model" name="default_model" value="' + esc(p.default_model || '') + '"></div></div>'
+ '<div id="pai-p-key-field"></div>'
+ '<div id="pai-p-key-field"></div>'
+ '<div class="pai-dialog-actions"><button type="button" class="btn" data-act="cancel">Cancel</button><button type="submit" class="btn btn-primary" data-act="save">' + (editing ? 'Save' : 'Add provider') + '</button></div>'
+ '<div class="pai-dialog-actions"><button type="button" class="btn" data-act="cancel">Cancel</button><button type="submit" class="btn btn-primary" data-act="save">' + (editing ? 'Save' : 'Add provider') + '</button></div>'
+ '</form>';
+ '</form>';
@@ -885,16 +964,16 @@ function paiProviderDialog(provider) {
@@ -885,16 +964,16 @@ function paiProviderDialog(provider) {
keyField.className = 'ov-field';
keyField.className = 'ov-field';
const keyLabel = document.createElement('label');
const keyLabel = document.createElement('label');
keyLabel.htmlFor = 'pai-p-value';
keyLabel.htmlFor = 'pai-p-value';
- keyLabel.textContent = editing ? 'Replace key (write-only)' : 'Key (write-only)';
+ keyLabel.textContent = editing ? 'Replace the API key (write-only)' : 'API key (write-only)';
const keyInput = document.createElement('input');
const keyInput = document.createElement('input');
keyInput.id = 'pai-p-value';
keyInput.id = 'pai-p-value';
keyInput.type = 'password';
keyInput.type = 'password';
keyInput.autocomplete = 'off';
keyInput.autocomplete = 'off';
keyInput.spellcheck = false;
keyInput.spellcheck = false;
- keyInput.placeholder = editing ? 'Leave empty to keep the key' : 'Paste the provider key';
+ keyInput.placeholder = editing ? 'Leave empty to keep the key' : 'Paste the provider\'s API key';
const keyHint = document.createElement('p');
const keyHint = document.createElement('p');
keyHint.className = 'ov-field-hint';
keyHint.className = 'ov-field-hint';
- keyHint.textContent = 'Sent once and sealed by OpenVibe.AI; it is never shown again.';
+ keyHint.textContent = 'Sent once and locked by OpenVibe.AI; it is never shown again.';
keyField.append(keyLabel, keyInput, keyHint);
keyField.append(keyLabel, keyInput, keyHint);
dlg.querySelector('#pai-p-key-field').append(keyField);
dlg.querySelector('#pai-p-key-field').append(keyField);
document.body.appendChild(dlg);
document.body.appendChild(dlg);
@@ -917,16 +996,15 @@ function paiProviderDialog(provider) {
@@ -917,16 +996,15 @@ function paiProviderDialog(provider) {
metadata: { ...(p.metadata || {}), provider_access: data.get('provider_access') },
metadata: { ...(p.metadata || {}), provider_access: data.get('provider_access') },
};
};
if (editing && p.base_url && body.base_url !== p.base_url && String(p.secret_ref || '').startsWith('vault:')) {
if (editing && p.base_url && body.base_url !== p.base_url && String(p.secret_ref || '').startsWith('vault:')) {
- const ok = await paiConfirm('Change the base URL?', ['Its sealed key is bound to the old address and will stop working until you enter it again.']);
+ const ok = await paiConfirm('Change the address?', ['Its locked key is only sent to the old address, so it stops working until you enter it again.', 'To undo it, put the old address back; the key works again.']);
if (!ok) return;
if (!ok) return;
}
}
const save = form.querySelector('[data-act="save"]');
const save = form.querySelector('[data-act="save"]');
save.disabled = true;
save.disabled = true;
save.textContent = 'Saving…';
save.textContent = 'Saving…';
try {
try {
- let created;
- if (editing) created = await paiApi('/providers/' + encodeURIComponent(p.key), { method: 'PATCH', body });
- else created = await paiApi('/providers', { method: 'POST', body });
+ if (editing) await paiApi('/providers/' + encodeURIComponent(p.key), { method: 'PATCH', body });
+ else await paiApi('/providers', { method: 'POST', body });
const key = body.key || p.key;
const key = body.key || p.key;
if (keyInput.value) {
if (keyInput.value) {
await paiApi('/providers/' + encodeURIComponent(key) + '/key', { method: 'PUT', body: { value: keyInput.value, reason: editing ? 'Replaced from the admin panel' : 'Added from the admin panel' } });
await paiApi('/providers/' + encodeURIComponent(key) + '/key', { method: 'PUT', body: { value: keyInput.value, reason: editing ? 'Replaced from the admin panel' : 'Added from the admin panel' } });
@@ -949,11 +1027,11 @@ function paiProviderDialog(provider) {
@@ -949,11 +1027,11 @@ function paiProviderDialog(provider) {
function paiAccessDesc(access) {
function paiAccessDesc(access) {
return {
return {
- OWN_DEPLOYMENT: 'A model on OpenVibe\'s own machine: no provider terms limit it.',
- AUTHORIZED_WHOLESALE: 'A wholesale agreement permits resale; check the agreement covers this model.',
- CUSTOMER_BYOK: 'The customer\'s own key: they pay the provider and the terms are theirs.',
- RESTRICTED: 'The provider\'s terms do not permit resale: do not sell this capacity.',
- UNVERIFIED: 'Not reviewed yet: treat it as restricted until someone checks the terms.',
+ OWN_DEPLOYMENT: 'A model on OpenVibe\'s own machine, so no provider\'s terms limit who may use it.',
+ AUTHORIZED_WHOLESALE: 'An agreement with the provider lets OpenVibe sell its use; check that it covers this model.',
+ CUSTOMER_BYOK: 'Only people who enter their own key for this provider can use it; they pay the provider.',
+ RESTRICTED: 'The provider\'s terms forbid selling its use, so OpenVibe never does.',
+ UNVERIFIED: 'Nobody has read the terms yet, so OpenVibe treats it as Do not sell until someone does.',
}[access] || '';
}[access] || '';
}
}
@@ -961,7 +1039,7 @@ async function paiKeyDialog(provider) {
@@ -961,7 +1039,7 @@ async function paiKeyDialog(provider) {
const dlg = document.createElement('dialog');
const dlg = document.createElement('dialog');
dlg.className = 'pai-dialog';
dlg.className = 'pai-dialog';
const state = paiKeyState(provider);
const state = paiKeyState(provider);
- dlg.innerHTML = '<form method="dialog" class="pai-form"><h3>Key for ' + esc(provider.display_name || provider.key) + '</h3>'
+ dlg.innerHTML = '<form method="dialog" class="pai-form"><h3>API key for ' + esc(provider.display_name || provider.key) + '</h3>'
+ '<p class="muted">' + state.text + '</p>'
+ '<p class="muted">' + state.text + '</p>'
+ '<div id="pai-key-field"></div>'
+ '<div id="pai-key-field"></div>'
+ '<div class="pai-dialog-actions"><button type="button" class="btn" data-act="cancel">Cancel</button><button type="submit" class="btn btn-primary" data-act="save">Save key</button></div></form>';
+ '<div class="pai-dialog-actions"><button type="button" class="btn" data-act="cancel">Cancel</button><button type="submit" class="btn btn-primary" data-act="save">Save key</button></div></form>';
@@ -969,7 +1047,7 @@ async function paiKeyDialog(provider) {
@@ -969,7 +1047,7 @@ async function paiKeyDialog(provider) {
field.className = 'ov-field';
field.className = 'ov-field';
const label = document.createElement('label');
const label = document.createElement('label');
label.htmlFor = 'pai-key-value';
label.htmlFor = 'pai-key-value';
- label.textContent = 'Provider key (write-only)';
+ label.textContent = 'API key (write-only)';
const input = document.createElement('input');
const input = document.createElement('input');
input.id = 'pai-key-value';
input.id = 'pai-key-value';
input.type = 'password';
input.type = 'password';
@@ -978,7 +1056,7 @@ async function paiKeyDialog(provider) {
@@ -978,7 +1056,7 @@ async function paiKeyDialog(provider) {
input.required = true;
input.required = true;
const hint = document.createElement('p');
const hint = document.createElement('p');
hint.className = 'ov-field-hint';
hint.className = 'ov-field-hint';
- hint.textContent = 'Sent once, sealed by OpenVibe.AI; the box is cleared and removed right after saving.';
+ hint.textContent = 'Sent once and locked by OpenVibe.AI; the box is cleared and removed right after saving.';
field.append(label, input, hint);
field.append(label, input, hint);
dlg.querySelector('#pai-key-field').append(field);
dlg.querySelector('#pai-key-field').append(field);
document.body.appendChild(dlg);
document.body.appendChild(dlg);
@@ -996,7 +1074,7 @@ async function paiKeyDialog(provider) {
@@ -996,7 +1074,7 @@ async function paiKeyDialog(provider) {
save.textContent = 'Saving…';
save.textContent = 'Saving…';
try {
try {
await paiApi('/providers/' + encodeURIComponent(provider.key) + '/key', { method: 'PUT', body: { value, reason: 'Replaced from the admin panel' } });
await paiApi('/providers/' + encodeURIComponent(provider.key) + '/key', { method: 'PUT', body: { value, reason: 'Replaced from the admin panel' } });
- toast('Key sealed', 'success');
+ toast('Key locked', 'success');
dlg.close();
dlg.close();
await loadPlatformAi();
await loadPlatformAi();
} catch (err) {
} catch (err) {
@@ -1016,7 +1094,7 @@ async function paiKeyDialog(provider) {
@@ -1016,7 +1094,7 @@ async function paiKeyDialog(provider) {
}
}
async function paiRemoveKey(provider) {
async function paiRemoveKey(provider) {
- const ok = await paiConfirm('Remove the key?', [paiLabel('ai') + ' will stop calling ' + (provider.display_name || provider.key) + ' until a key is entered again.']);
+ const ok = await paiConfirm('Remove the key?', [paiLabel('ai') + ' stops calling ' + (provider.display_name || provider.key) + ' until a key is entered again.', 'To undo it, enter the key again with Add key.']);
if (!ok) return;
if (!ok) return;
try {
try {
await paiApi('/providers/' + encodeURIComponent(provider.key) + '/key', { method: 'DELETE', body: { reason: 'Removed from the admin panel' } });
await paiApi('/providers/' + encodeURIComponent(provider.key) + '/key', { method: 'DELETE', body: { reason: 'Removed from the admin panel' } });
@@ -1028,25 +1106,43 @@ async function paiRemoveKey(provider) {
@@ -1028,25 +1106,43 @@ async function paiRemoveKey(provider) {
}
}
function paiRenderCards() {
function paiRenderCards() {
- paiFillCards('exchange', PAI.namespaces.filter((n) => n.service === 'ai' && !n.namespace.endsWith('.provider_keys')), 'OpenVibe.AI has not published the ai.exchange namespace yet.', 'AI Exchange');
- paiFillCards('money', PAI.namespaces.filter((n) => n.service === 'billing'), 'OpenVibe.Billing has not published a settings namespace yet.', 'Money');
- paiFillCards('services', PAI.namespaces.filter((n) => n.service !== 'ai' && n.service !== 'billing' && !n.namespace.endsWith('.provider_keys')), 'No other service has published settings yet.', 'Services');
+ const all = paiAiNamespaces();
+ paiFillCards('exchange', all.filter((n) => n.service === 'ai'), 'OpenVibe.AI has not published what the Exchange offers yet.', 'What the Exchange offers');
+ paiFillCards('money', all.filter((n) => n.service === 'billing'), 'OpenVibe.Billing has not published its money settings yet.', 'Money');
+ paiFillCards('services', all.filter((n) => n.service !== 'ai' && n.service !== 'billing'), 'No other service has published AI settings yet.', 'Other services\' AI settings');
}
}
function paiFillCards(group, list, empty, sectionTitle) {
function paiFillCards(group, list, empty, sectionTitle) {
const box = document.getElementById('pai-cards-' + group);
const box = document.getElementById('pai-cards-' + group);
if (!box) return;
if (!box) return;
- if (!list.length) { box.innerHTML = '<p class="muted">' + empty + '</p>'; return; }
+ if (!list.length) { box.innerHTML = '<p class="muted">' + esc(empty) + '</p>'; return; }
const groups = new Map();
const groups = new Map();
for (const n of list) {
for (const n of list) {
- const name = (n.schema && n.schema['x-group']) || 'General';
+ const name = (n.schema && n.schema['x-group']) || paiLabel(n.service);
if (!groups.has(name)) groups.set(name, []);
if (!groups.has(name)) groups.set(name, []);
groups.get(name).push(n);
groups.get(name).push(n);
}
}
- // One group (or no x-group) needs no heading: the section's own title already says it.
+ // One group needs no heading: the section's own title already says it.
const showGroups = groups.size > 1;
const showGroups = groups.size > 1;
const single = list.length === 1;
const single = list.length === 1;
- box.innerHTML = [...groups.entries()].map(([name, items]) => (showGroups ? '<h4 class="pai-group">' + esc(name) + '</h4>' : '') + items.map((n) => paiCard(n, { hideTitle: single && (n.schema && (n.schema.title || n.namespace)) === sectionTitle })).join('')).join('');
+ box.innerHTML = [...groups.entries()].map(([name, items]) => (showGroups ? '<h4 class="pai-group">' + esc(name) + '</h4>' : '') + items.map((n) => paiCard(n, { hideTitle: single && (n.schema && n.schema.title) === sectionTitle })).join('')).join('');
+}
+
+/** A field folds under Advanced settings when its schema says x-advanced, or when it is raw JSON (an object with no
+ * described properties): raw JSON is never in the main form. */
+function paiAdvanced(prop) {
+ const types = Array.isArray(prop.type) ? prop.type : [prop.type];
+ const type = types.find((t) => t !== 'null') || 'string';
+ const raw = type === 'object' && !(prop.properties && typeof prop.properties === 'object' && Object.keys(prop.properties).length);
+ return prop['x-advanced'] === true || raw;
+}
+
+/** Who saved a version, in words: you, a service by name, or another account (never a raw usr_ id). */
+function paiWho(ref) {
+ if (!ref || !ref.id) return '';
+ if (currentUser && ref.id === currentUser.subject_id) return '@' + currentUser.username;
+ if (ref.type === 'service' || ref.type === 'system') return paiLabel(ref.id);
+ return 'another account';
}
}
function paiCard(n, opts = {}) {
function paiCard(n, opts = {}) {
@@ -1054,65 +1150,114 @@ function paiCard(n, opts = {}) {
@@ -1054,65 +1150,114 @@ function paiCard(n, opts = {}) {
const props = schema.properties || {};
const props = schema.properties || {};
const pins = Array.isArray(n.pins) ? n.pins : (n.pins && typeof n.pins === 'object' ? Object.keys(n.pins) : []);
const pins = Array.isArray(n.pins) ? n.pins : (n.pins && typeof n.pins === 'object' ? Object.keys(n.pins) : []);
const pinEnv = n.pin_env || {};
const pinEnv = n.pin_env || {};
- const fields = Object.entries(props).map(([key, prop]) => paiField(n, key, prop, pins.includes(key), pinEnv[key])).join('');
- const actor = n.active && n.active.activated_by && n.active.activated_by.id ? n.active.activated_by.id : (n.active && n.active.created_by && n.active.created_by.id ? n.active.created_by.id : null);
- const who = actor ? (currentUser && actor === currentUser.subject_id ? '@' + currentUser.username : actor) : '';
+ const entries = Object.entries(props).filter(([, prop]) => prop && typeof prop === 'object');
+ const main = entries.filter(([, prop]) => !paiAdvanced(prop));
+ const advanced = entries.filter(([, prop]) => paiAdvanced(prop));
+ const fieldOf = ([key, prop]) => paiField(n, key, prop, pins.includes(key), pinEnv[key]);
+ const fields = main.map(fieldOf).join('');
+ const fold = advanced.length
+ ? '<details class="pai-advanced"><summary>Advanced settings (' + advanced.length + ')</summary><div class="pai-fields">' + advanced.map(fieldOf).join('') + '</div></details>'
+ : '';
+ const who = paiWho(n.active && (n.active.activated_by || n.active.created_by));
const at = n.active && (n.active.activated_at || n.active.created_at);
const at = n.active && (n.active.activated_at || n.active.created_at);
- const revised = n.revision == null ? 'Not published' : 'Revision ' + esc(n.revision);
- const title = opts.hideTitle ? '' : '<h5>' + esc(schema.title || n.namespace) + '</h5>';
+ const revised = n.revision == null ? 'Never changed: these are the defaults' : 'Saved version ' + esc(n.revision);
+ const title = opts.hideTitle ? '' : '<h5>' + esc(schema.title || paiHumanize(n.namespace.split('.').slice(1).join(' '))) + ' <code class="pai-mkey">' + esc(n.namespace) + '</code></h5>';
const description = schema.description ? '<p class="muted">' + esc(schema.description) + '</p>' : '';
const description = schema.description ? '<p class="muted">' + esc(schema.description) + '</p>' : '';
const head = title || description ? '<header class="pai-card-head">' + title + description + '</header>' : '';
const head = title || description ? '<header class="pai-card-head">' + title + description + '</header>' : '';
return '<article class="pai-card" data-pai-card data-service="' + esc(n.service) + '" data-namespace="' + esc(n.namespace) + '">'
return '<article class="pai-card" data-pai-card data-service="' + esc(n.service) + '" data-namespace="' + esc(n.namespace) + '">'
+ head
+ head
- + '<div class="pai-fields">' + fields + '</div>'
- + '<footer class="pai-card-foot"><span class="pai-revision">' + revised + (who ? ' · ' + esc(who) : '') + (at ? ' · ' + esc(paiWhen(at)) : '') + '</span>'
+ + (fields ? '<div class="pai-fields">' + fields + '</div>' : '')
+ + fold
+ + '<footer class="pai-card-foot"><span class="pai-revision">' + revised + (who ? ' · by ' + esc(who) : '') + (at ? ' · ' + esc(paiWhen(at)) : '') + ' ' + paiWhat('version', 'What is a version?') + '</span>'
+ '<span class="pai-card-actions"><button type="button" class="btn btn-sm pai-quiet" data-act="history">History</button><button type="button" class="btn btn-sm btn-primary" data-act="save">Save</button></span></footer>'
+ '<span class="pai-card-actions"><button type="button" class="btn btn-sm pai-quiet" data-act="history">History</button><button type="button" class="btn btn-sm btn-primary" data-act="save">Save</button></span></footer>'
+ '</article>';
+ '</article>';
}
}
-function paiField(n, key, prop, pinned, pinName) {
- const value = n.values ? n.values[key] : undefined;
- const cls = (n.classification && n.classification[key]) || 'internal';
- const danger = !!prop['x-danger'];
- const title = prop.title || key;
- const description = prop.description || '';
+/** One choice's options: the schema's x-enum-labels in words with their line, or the value in words when it has none. */
+function paiOptions(prop, value) {
+ const labels = prop['x-enum-labels'] && typeof prop['x-enum-labels'] === 'object' ? prop['x-enum-labels'] : {};
+ return prop.enum.map((v) => {
+ const k = typeof v === 'string' ? v : JSON.stringify(v);
+ const l = labels[k] && typeof labels[k] === 'object' ? labels[k] : null;
+ const text = l && l.label ? l.label : (v === null ? 'None' : paiHumanize(String(v)));
+ return '<option value="' + esc(k) + '"' + (String(value) === k ? ' selected' : '') + (l && l.description ? ' data-desc="' + esc(l.description) + '"' : '') + '>' + esc(text) + '</option>';
+ }).join('');
+}
+
+/** The input for one value. `path` is the key, or "parent.child" for a field of an object drawn as a form. */
+function paiControl(path, prop, value, { pinned = false, title = '', nested = false } = {}) {
const types = Array.isArray(prop.type) ? prop.type : [prop.type];
const types = Array.isArray(prop.type) ? prop.type : [prop.type];
const type = types.find((t) => t !== 'null') || 'string';
const type = types.find((t) => t !== 'null') || 'string';
const nullable = types.includes('null');
const nullable = types.includes('null');
- const chips = (pinned ? '<span class="pai-chip-lock">🔒 Pinned</span>' : '') + (danger ? '<span class="pai-chip-danger">' + esc(/money|credit|spend|pay|price|usd/i.test(title + ' ' + description + ' ' + key) ? 'Money' : 'Danger') + '</span>' : '') + (cls === 'secret' ? '<span class="pai-chip-secret">Secret</span>' : '');
const dis = pinned ? ' disabled' : '';
const dis = pinned ? ' disabled' : '';
- let control;
- if (cls === 'secret') {
- const marker = paiMarker(value);
- control = '<div class="pai-secret"><span class="pai-marker">' + (marker ? 'Sealed ••••' + esc(marker.hint || '') : 'Not set') + '</span>'
- + (pinned ? '' : ' <button type="button" class="btn btn-sm pai-quiet" data-act="secret" data-key="' + esc(key) + '">' + (marker ? 'Replace key' : 'Set key') + '</button>')
- + (!pinned && marker ? ' <button type="button" class="btn btn-sm pai-quiet pai-quiet-danger" data-act="secret-remove" data-key="' + esc(key) + '">Remove</button>' : '') + '</div>';
- } else if (prop.enum && Array.isArray(prop.enum)) {
- control = '<select data-pai-control data-type="enum" data-key="' + esc(key) + '" aria-label="' + esc(title) + '"' + (pinned ? ' data-ov-native' : '') + dis + '>' + prop.enum.map((v) => '<option value="' + esc(v) + '"' + (String(value) === String(v) ? ' selected' : '') + '>' + esc(v) + '</option>').join('') + '</select>';
- } else if (type === 'boolean') {
- control = '<label class="pai-switch"><input type="checkbox" role="switch" data-ov-native data-pai-control data-type="boolean" data-key="' + esc(key) + '"' + (value ? ' checked' : '') + dis + '><span class="pai-track" aria-hidden="true"><span class="pai-knob"></span></span><span class="pai-switch-text">' + (value ? 'On' : 'Off') + '</span></label>';
- } else if (type === 'array') {
+ const nest = nested ? ' data-nested="1"' : '';
+ const placeholder = ['string', 'number'].includes(typeof prop['x-example']) ? ' placeholder="' + esc(prop['x-example']) + '"' : '';
+ if (prop.enum && Array.isArray(prop.enum)) {
+ return '<select data-pai-control data-type="enum" data-key="' + esc(path) + '"' + nest + ' aria-label="' + esc(title) + '"' + (pinned ? ' data-ov-native' : '') + dis + '>' + paiOptions(prop, value) + '</select>';
+ }
+ if (type === 'boolean') {
+ return '<label class="pai-switch"><input type="checkbox" role="switch" data-ov-native data-pai-control data-type="boolean" data-key="' + esc(path) + '"' + nest + (value ? ' checked' : '') + dis + ' aria-label="' + esc(title) + '"><span class="pai-track" aria-hidden="true"><span class="pai-knob"></span></span><span class="pai-switch-text">' + (value ? 'On' : 'Off') + '</span></label>';
+ }
+ if (type === 'array') {
const items = Array.isArray(value) ? value : [];
const items = Array.isArray(value) ? value : [];
- control = '<div class="pai-chips" data-pai-chips>' + items.map((it) => paiChip(it, !pinned)).join('') + '</div>'
+ return '<div class="pai-chips" data-pai-chips>' + items.map((it) => paiChip(it, !pinned)).join('') + '</div>'
+ (pinned ? '' : '<span class="pai-list-add"><input data-pai-list-input placeholder="Add…" aria-label="Add to ' + esc(title) + '"><button type="button" class="btn btn-sm pai-quiet" data-act="list-add">Add</button></span>');
+ (pinned ? '' : '<span class="pai-list-add"><input data-pai-list-input placeholder="Add…" aria-label="Add to ' + esc(title) + '"><button type="button" class="btn btn-sm pai-quiet" data-act="list-add">Add</button></span>');
- } else if (type === 'object') {
- control = '<textarea data-pai-control data-type="object" data-key="' + esc(key) + '" rows="3" spellcheck="false" aria-label="' + esc(title) + '"' + dis + '>' + esc(JSON.stringify(value == null ? {} : value, null, 2)) + '</textarea>';
- } else if (prop['x-unit'] === 'nano_usd') {
- control = '<span class="pai-money-input"><span class="pai-prefix">$</span><input data-pai-control data-type="nano_usd" data-key="' + esc(key) + '" inputmode="decimal" value="' + esc(paiUsd(value)) + '" aria-label="' + esc(title) + '"' + dis + '></span>';
- } else if (type === 'integer') {
+ }
+ if (type === 'object') {
+ // Raw JSON: only ever drawn inside Advanced settings (paiAdvanced).
+ return '<textarea data-pai-control data-type="object" data-key="' + esc(path) + '"' + nest + ' rows="3" spellcheck="false" aria-label="' + esc(title) + '"' + dis + '>' + esc(JSON.stringify(value == null ? {} : value, null, 2)) + '</textarea>';
+ }
+ if (prop['x-unit'] === 'nano_usd') {
+ return '<span class="pai-money-input"><span class="pai-prefix">$</span><input data-pai-control data-type="nano_usd" data-key="' + esc(path) + '"' + nest + ' inputmode="decimal" value="' + esc(paiUsd(value)) + '" aria-label="' + esc(title) + ' in dollars"' + dis + '></span>';
+ }
+ if (type === 'integer') {
const unit = paiUnitLabel(prop['x-unit']);
const unit = paiUnitLabel(prop['x-unit']);
- const input = '<input data-pai-control data-type="integer" data-key="' + esc(key) + '"' + (nullable ? ' data-nullable="1"' : '') + ' type="number" step="1" value="' + esc(value == null ? '' : value) + '" aria-label="' + esc(title) + '"' + dis + '>';
- control = unit ? '<span class="pai-unit-input">' + input + '<span class="pai-unit">' + esc(unit) + '</span></span>' : input;
- } else if (type === 'number') {
- control = '<input data-pai-control data-type="number" data-key="' + esc(key) + '"' + (nullable ? ' data-nullable="1"' : '') + ' type="number" step="any" value="' + esc(value == null ? '' : value) + '" aria-label="' + esc(title) + '"' + dis + '>';
+ const input = '<input data-pai-control data-type="integer" data-key="' + esc(path) + '"' + nest + (nullable ? ' data-nullable="1"' : '') + ' type="number" step="1" value="' + esc(value == null ? '' : value) + '" aria-label="' + esc(title) + (unit ? ' in ' + esc(PAI_UNIT_WORDS[prop['x-unit']] || unit) : '') + '"' + placeholder + dis + '>';
+ return unit ? '<span class="pai-unit-input">' + input + '<span class="pai-unit">' + esc(unit) + '</span></span>' : input;
+ }
+ if (type === 'number') {
+ return '<input data-pai-control data-type="number" data-key="' + esc(path) + '"' + nest + (nullable ? ' data-nullable="1"' : '') + ' type="number" step="any" value="' + esc(value == null ? '' : value) + '" aria-label="' + esc(title) + '"' + placeholder + dis + '>';
+ }
+ return '<input data-pai-control data-type="string" data-key="' + esc(path) + '"' + nest + (nullable ? ' data-nullable="1"' : '') + ' value="' + esc(value == null ? '' : value) + '" aria-label="' + esc(title) + '"' + placeholder + dis + '>';
+}
+
+function paiField(n, key, prop, pinned, pinName) {
+ const value = n.values ? n.values[key] : undefined;
+ const secret = paiIsSecret(n, key, prop);
+ const danger = !!prop['x-danger'];
+ const title = prop.title || paiHumanize(key);
+ const description = prop.description || '';
+ const types = Array.isArray(prop.type) ? prop.type : [prop.type];
+ const type = types.find((t) => t !== 'null') || 'string';
+ const money = /money|credit|spend|pay|price|usd|cost|cap/i.test(title + ' ' + description + ' ' + key) || prop['x-unit'] === 'nano_usd';
+ const chips = (pinned ? '<span class="pai-chip-lock">From the settings file</span>' : '') + (danger ? '<span class="pai-chip-danger">' + (money ? 'Money' : 'Takes effect at once') + '</span>' : '') + (secret ? '<span class="pai-chip-secret">Key</span>' : '');
+ let control;
+ if (secret) {
+ const m = (n.secret_markers && n.secret_markers[key]) || null;
+ const set = !!(value && typeof value === 'object' && value.redacted === true);
+ const shown = pinned ? 'From the server\'s settings file' + (pinName || prop['x-env'] ? ' (' + esc(pinName || prop['x-env']) + ')' : '')
+ : set ? 'Entered here, locked' + (m && m.hint ? ' ••••' + esc(m.hint) : '') + (m && m.set_at ? ' · ' + esc(paiWhen(m.set_at)) : '') : 'Not set';
+ control = '<div class="pai-secret"><span class="pai-marker">' + shown + '</span>'
+ + (pinned ? '' : ' <button type="button" class="btn btn-sm pai-quiet" data-act="secret" data-key="' + esc(key) + '">' + (set ? 'Replace' : 'Enter it') + '</button>')
+ + (!pinned && set ? ' <button type="button" class="btn btn-sm pai-quiet pai-quiet-danger" data-act="secret-remove" data-key="' + esc(key) + '">Remove</button>' : '') + '</div>';
+ } else if (type === 'object' && prop.properties && typeof prop.properties === 'object' && Object.keys(prop.properties).length) {
+ // An object whose fields the schema describes is a small form, not raw JSON.
+ const inner = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
+ control = '<div class="pai-subform" data-pai-object data-key="' + esc(key) + '">' + Object.entries(prop.properties).map(([k, p]) => {
+ const t = (p && p.title) || paiHumanize(k);
+ return '<div class="pai-subfield"><span class="pai-subfield-label">' + esc(t) + ' <code>' + esc(k) + '</code></span>'
+ + (p && p.description ? '<span class="pai-field-desc muted">' + esc(p.description) + '</span>' : '')
+ + paiControl(key + '.' + k, p || {}, inner[k], { pinned, title: t, nested: true }) + '</div>';
+ }).join('') + '</div>';
} else {
} else {
- control = '<input data-pai-control data-type="string" data-key="' + esc(key) + '"' + (nullable ? ' data-nullable="1"' : '') + ' value="' + esc(value == null ? '' : value) + '" aria-label="' + esc(title) + '"' + dis + '>';
+ control = paiControl(key, prop, value, { pinned, title });
}
}
- const pinLine = pinned ? '<p class="pai-pin-line">Pinned by ' + esc(pinName || (prop['x-env'] || 'the environment')) + ' in the env file</p>' : '';
- return '<div class="pai-field" data-pai-field data-key="' + esc(key) + '" data-secret="' + (cls === 'secret' ? '1' : '0') + '" data-pinned="' + (pinned ? '1' : '0') + '">'
+ const unitWords = prop['x-unit'] && prop['x-unit'] !== 'nano_usd' && PAI_UNIT_WORDS[prop['x-unit']] ? '<p class="pai-unit-line muted">In ' + esc(PAI_UNIT_WORDS[prop['x-unit']]) + '.</p>' : '';
+ const pinLine = pinned ? '<p class="pai-pin-line">Set in the server\'s settings file (' + esc(pinName || prop['x-env'] || 'its env file') + '); change it there. ' + paiWhat('settings-file') + '</p>' : '';
+ return '<div class="pai-field" data-pai-field data-key="' + esc(key) + '" data-secret="' + (secret ? '1' : '0') + '" data-pinned="' + (pinned ? '1' : '0') + '">'
+ '<div class="pai-field-label"><label>' + esc(title) + ' <code>' + esc(key) + '</code></label>' + chips + '</div>'
+ '<div class="pai-field-label"><label>' + esc(title) + ' <code>' + esc(key) + '</code></label>' + chips + '</div>'
+ (description ? '<p class="pai-field-desc muted">' + esc(description) + '</p>' : '')
+ (description ? '<p class="pai-field-desc muted">' + esc(description) + '</p>' : '')
- + '<div class="pai-control">' + control + pinLine + '</div></div>';
+ + '<div class="pai-control">' + control + unitWords + pinLine + '</div></div>';
}
}
function paiChip(value, removable = true) {
function paiChip(value, removable = true) {
@@ -1148,37 +1293,63 @@ async function paiCardClick(ev) {
@@ -1148,37 +1293,63 @@ async function paiCardClick(ev) {
}
}
}
}
+/** One control's value, or { error }. */
+function paiReadControl(control, label) {
+ const type = control.dataset.type;
+ if (type === 'boolean') return { value: control.checked };
+ if (control.dataset.nullable === '1' && control.value === '') return { value: null };
+ if (type === 'nano_usd') {
+ const parsed = paiParseUsd(control.value);
+ return parsed.error ? { error: label + ': ' + parsed.error } : { value: parsed.nano };
+ }
+ if (type === 'integer') {
+ const number = Number(control.value);
+ return Number.isInteger(number) && control.value !== '' ? { value: number } : { error: label + ': enter a whole number' };
+ }
+ if (type === 'number') {
+ const number = Number(control.value);
+ return Number.isFinite(number) && control.value !== '' ? { value: number } : { error: label + ': enter a number' };
+ }
+ if (type === 'object') {
+ try { return { value: JSON.parse(control.value) }; } catch { return { error: label + ': the JSON is not valid' }; }
+ }
+ if (type === 'enum') {
+ // An option's value is the enum value as text; a non-string one is its JSON.
+ const select = control;
+ try { const v = JSON.parse(select.value); if (typeof v !== 'string') return { value: v }; } catch { /* a plain string */ }
+ return { value: select.value };
+ }
+ return { value: control.value };
+}
+
function paiCollect(card) {
function paiCollect(card) {
const values = {};
const values = {};
for (const field of card.querySelectorAll('[data-pai-field]')) {
for (const field of card.querySelectorAll('[data-pai-field]')) {
if (field.dataset.pinned === '1' || field.dataset.secret === '1') continue;
if (field.dataset.pinned === '1' || field.dataset.secret === '1') continue;
- const control = field.querySelector('[data-pai-control]');
- if (!control) continue;
const key = field.dataset.key;
const key = field.dataset.key;
if (!key) continue;
if (!key) continue;
- if (control.dataset.type === 'boolean') values[key] = control.checked;
- else if (control.dataset.nullable === '1' && control.value === '') values[key] = null;
- else if (control.dataset.type === 'nano_usd') {
- const parsed = paiParseUsd(control.value);
- if (parsed.error) return { error: control.dataset.key + ': ' + parsed.error };
- values[key] = parsed.nano;
- } else if (control.dataset.type === 'integer') {
- const number = Number(control.value);
- if (!Number.isInteger(number)) return { error: key + ': enter a whole number' };
- values[key] = number;
- } else if (control.dataset.type === 'number') {
- const number = Number(control.value);
- if (!Number.isFinite(number)) return { error: key + ': enter a number' };
- values[key] = number;
- } else if (control.dataset.type === 'enum' || control.dataset.type === 'string') values[key] = control.value;
- else if (control.dataset.type === 'object') {
- try { values[key] = JSON.parse(control.value); } catch { return { error: key + ': the JSON is not valid' }; }
+ const label = (field.querySelector('.pai-field-label label') || {}).textContent || key;
+ const object = field.querySelector('[data-pai-object]');
+ if (object) {
+ const out = {};
+ for (const control of object.querySelectorAll('[data-pai-control][data-nested="1"]')) {
+ const child = control.dataset.key.slice(key.length + 1);
+ const read = paiReadControl(control, label.trim() + ' · ' + child);
+ if (read.error) return { error: read.error };
+ out[child] = read.value;
+ }
+ values[key] = out;
+ continue;
}
}
- }
- for (const field of card.querySelectorAll('[data-pai-field]')) {
if (field.querySelector('[data-pai-chips]')) {
if (field.querySelector('[data-pai-chips]')) {
- values[field.dataset.key] = [...field.querySelectorAll('.pai-chip')].map((chip) => JSON.parse(chip.dataset.value));
+ values[key] = [...field.querySelectorAll('.pai-chip')].map((chip) => JSON.parse(chip.dataset.value));
+ continue;
}
}
+ const control = field.querySelector('[data-pai-control]');
+ if (!control) continue;
+ const read = paiReadControl(control, label.trim());
+ if (read.error) return { error: read.error };
+ values[key] = read.value;
}
}
return { values };
return { values };
}
}
@@ -1188,13 +1359,17 @@ async function paiSaveCard(card, n) {
@@ -1188,13 +1359,17 @@ async function paiSaveCard(card, n) {
if (collected.error) return toast(collected.error, 'error');
if (collected.error) return toast(collected.error, 'error');
const changed = {};
const changed = {};
for (const [key, value] of Object.entries(collected.values)) {
for (const [key, value] of Object.entries(collected.values)) {
- if (JSON.stringify(value) !== JSON.stringify(n.values ? n.values[key] : undefined)) changed[key] = value;
+ const before = n.values ? n.values[key] : undefined;
+ // An object drawn as a form keeps the members it did not draw.
+ const next = value && typeof value === 'object' && !Array.isArray(value) && before && typeof before === 'object' && !Array.isArray(before) ? { ...before, ...value } : value;
+ if (JSON.stringify(next) !== JSON.stringify(before)) changed[key] = next;
}
}
if (!Object.keys(changed).length) return toast('Nothing to save', 'info');
if (!Object.keys(changed).length) return toast('Nothing to save', 'info');
const props = (n.schema && n.schema.properties) || {};
const props = (n.schema && n.schema.properties) || {};
const danger = Object.keys(changed).filter((key) => props[key] && props[key]['x-danger']);
const danger = Object.keys(changed).filter((key) => props[key] && props[key]['x-danger']);
if (danger.length) {
if (danger.length) {
- const lines = danger.map((key) => (props[key].title || key) + ': ' + (props[key].description || 'This change takes effect at once.') + ' Now: ' + (changed[key] ? 'on' : 'off') + '.');
+ const lines = danger.map((key) => (props[key].title || paiHumanize(key)) + ': ' + (props[key].description || 'This takes effect at once.') + ' It will be ' + (typeof changed[key] === 'boolean' ? (changed[key] ? 'on' : 'off') : (props[key]['x-unit'] === 'nano_usd' ? '$' + paiUsd(changed[key]) : JSON.stringify(changed[key]))) + '.');
+ lines.push('To undo it, change it back here, or roll back to the previous version under History.');
const ok = await paiConfirm('Save this money setting?', lines);
const ok = await paiConfirm('Save this money setting?', lines);
if (!ok) return;
if (!ok) return;
}
}
@@ -1214,9 +1389,10 @@ async function paiSaveCard(card, n) {
@@ -1214,9 +1389,10 @@ async function paiSaveCard(card, n) {
}
}
function paiSecretDialog(n, key) {
function paiSecretDialog(n, key) {
+ const prop = (n.schema && n.schema.properties && n.schema.properties[key]) || {};
const dlg = document.createElement('dialog');
const dlg = document.createElement('dialog');
dlg.className = 'pai-dialog';
dlg.className = 'pai-dialog';
- dlg.innerHTML = '<form method="dialog" class="pai-form"><h3>' + esc(key) + '</h3><p class="muted">The value is written once, sealed by ' + esc(paiLabel(n.service)) + '; it is never shown again.</p>'
+ dlg.innerHTML = '<form method="dialog" class="pai-form"><h3>' + esc(prop.title || paiHumanize(key)) + ' <code class="pai-mkey">' + esc(key) + '</code></h3><p class="muted">The value is written once and locked by ' + esc(paiLabel(n.service)) + '; it is never shown again.</p>'
+ '<div id="pai-secret-field"></div><div class="pai-dialog-actions"><button type="button" class="btn" data-act="cancel">Cancel</button><button type="submit" class="btn btn-primary" data-act="save">Save key</button></div></form>';
+ '<div id="pai-secret-field"></div><div class="pai-dialog-actions"><button type="button" class="btn" data-act="cancel">Cancel</button><button type="submit" class="btn btn-primary" data-act="save">Save key</button></div></form>';
const field = document.createElement('div');
const field = document.createElement('div');
field.className = 'ov-field';
field.className = 'ov-field';
@@ -1263,7 +1439,8 @@ function paiSecretDialog(n, key) {
@@ -1263,7 +1439,8 @@ function paiSecretDialog(n, key) {
}
}
async function paiRemoveSecret(n, key) {
async function paiRemoveSecret(n, key) {
- const ok = await paiConfirm('Remove ' + key + '?', [paiLabel(n.service) + ' will not have this value until it is entered again.']);
+ const prop = (n.schema && n.schema.properties && n.schema.properties[key]) || {};
+ const ok = await paiConfirm('Remove ' + (prop.title || paiHumanize(key)) + '?', [paiLabel(n.service) + ' will not have this key until it is entered again; what uses it stops working.', 'It cannot be shown again, so keep a copy where you got it.']);
if (!ok) return;
if (!ok) return;
try {
try {
await paiApi('/' + encodeURIComponent(n.service) + '/' + encodeURIComponent(n.namespace) + '/secrets/' + encodeURIComponent(key), { method: 'DELETE', body: { reason: 'Removed from the admin panel' } });
await paiApi('/' + encodeURIComponent(n.service) + '/' + encodeURIComponent(n.namespace) + '/secrets/' + encodeURIComponent(key), { method: 'DELETE', body: { reason: 'Removed from the admin panel' } });
@@ -1285,12 +1462,13 @@ async function paiHistoryDialog(n) {
@@ -1285,12 +1462,13 @@ async function paiHistoryDialog(n) {
const dlg = document.createElement('dialog');
const dlg = document.createElement('dialog');
dlg.className = 'pai-dialog pai-dialog-wide';
dlg.className = 'pai-dialog pai-dialog-wide';
const rows = snapshots.map((s) => {
const rows = snapshots.map((s) => {
- const actor = s.activated_by && s.activated_by.id ? s.activated_by.id : (s.created_by && s.created_by.id ? s.created_by.id : '');
+ const actor = paiWho(s.activated_by || s.created_by);
const current = s.state === 'active' || s.revision === n.revision;
const current = s.state === 'active' || s.revision === n.revision;
return '<tr><td>' + esc(s.revision) + '</td><td>' + esc(paiWhen(s.activated_at || s.created_at)) + '</td><td>' + esc(actor || '—') + '</td><td>' + esc(s.reason || '—') + '</td><td>' + (current ? '<span class="muted">Current</span>' : '<button type="button" class="btn btn-sm" data-rollback="' + esc(s.revision) + '">Roll back</button>') + '</td></tr>';
return '<tr><td>' + esc(s.revision) + '</td><td>' + esc(paiWhen(s.activated_at || s.created_at)) + '</td><td>' + esc(actor || '—') + '</td><td>' + esc(s.reason || '—') + '</td><td>' + (current ? '<span class="muted">Current</span>' : '<button type="button" class="btn btn-sm" data-rollback="' + esc(s.revision) + '">Roll back</button>') + '</td></tr>';
}).join('');
}).join('');
- dlg.innerHTML = '<div class="pai-form"><h3>History · ' + esc(n.namespace) + '</h3>'
- + (snapshots.length ? '<div class="pai-table-wrap"><table class="pai-table"><thead><tr><th>Revision</th><th>When</th><th>By</th><th>Reason</th><th></th></tr></thead><tbody>' + rows + '</tbody></table></div>' : '<p class="muted">No revisions yet.</p>')
+ dlg.innerHTML = '<div class="pai-form"><h3>History · ' + esc((n.schema && n.schema.title) || n.namespace) + ' <code class="pai-mkey">' + esc(n.namespace) + '</code></h3>'
+ + '<p class="muted">Every saved version, newest first. Rolling back makes an older version current again, as a new version; nothing is deleted.</p>'
+ + (snapshots.length ? '<div class="pai-table-wrap"><table class="pai-table"><thead><tr><th>Version</th><th>When</th><th>By</th><th>Why</th><th></th></tr></thead><tbody>' + rows + '</tbody></table></div>' : '<p class="muted">No saved versions yet: these are the defaults.</p>')
+ '<div class="pai-dialog-actions"><button type="button" class="btn" data-act="close">Close</button></div></div>';
+ '<div class="pai-dialog-actions"><button type="button" class="btn" data-act="close">Close</button></div></div>';
document.body.appendChild(dlg);
document.body.appendChild(dlg);
dlg.addEventListener('click', async (ev) => {
dlg.addEventListener('click', async (ev) => {
@@ -1298,7 +1476,7 @@ async function paiHistoryDialog(n) {
@@ -1298,7 +1476,7 @@ async function paiHistoryDialog(n) {
const rollback = ev.target.closest('[data-rollback]');
const rollback = ev.target.closest('[data-rollback]');
if (!rollback) return;
if (!rollback) return;
const to = Number(rollback.dataset.rollback);
const to = Number(rollback.dataset.rollback);
- const ok = await paiConfirm('Roll back to revision ' + to + '?', ['The namespace returns to that revision. Nothing is deleted; the rollback is recorded as a new revision.']);
+ const ok = await paiConfirm('Roll back to version ' + to + '?', ['These settings return to version ' + to + '. Nothing is deleted: the rollback is kept as a new version, so you can roll forward again.']);
if (!ok) return;
if (!ok) return;
rollback.disabled = true;
rollback.disabled = true;
try {
try {
@@ -1332,6 +1510,7 @@ function paiConfirm(title, lines) {
@@ -1332,6 +1510,7 @@ function paiConfirm(title, lines) {
});
});
}
}
+/** Per service, in words: locked with its own lock key, not locked, or not published yet. */
function paiRenderVault() {
function paiRenderVault() {
const box = document.getElementById('pai-vault-list');
const box = document.getElementById('pai-vault-list');
if (!box) return;
if (!box) return;
@@ -1341,14 +1520,16 @@ function paiRenderVault() {
@@ -1341,14 +1520,16 @@ function paiRenderVault() {
let status;
let status;
let hint = '';
let hint = '';
if (state.answered && state.sealed === true) {
if (state.answered && state.sealed === true) {
- status = '<span class="pai-state is-ok">Sealed' + (state.kek ? ' · ' + esc(state.kek) : '') + '</span>';
+ status = '<span class="pai-state is-ok">Keys are locked</span>' + (state.kek ? ' <code class="pai-mkey">' + esc(state.kek) + '</code>' : '');
} else if (state.answered && state.sealed === false) {
} else if (state.answered && state.sealed === false) {
- status = '<span class="pai-state is-bad">Not sealed</span>';
- hint = '<div class="pai-hint">the service needs openvibe-shared 4.110 or later and OV_CONFIG_KEKS</div>';
+ status = '<span class="pai-state is-bad">Keys are stored unlocked</span>';
+ hint = '<div class="pai-hint">Add its lock key (OV_CONFIG_KEKS and OV_CONFIG_KEK_CURRENT) to its settings file on the server, with openvibe-shared 4.110 or later.</div>';
+ } else if (state.answered && !state.published) {
+ status = '<span class="pai-state is-muted">' + name + ' has not published settings yet</span>';
} else if (state.answered) {
} else if (state.answered) {
- status = '<span class="pai-state is-muted">No settings yet</span>';
+ status = '<span class="pai-state is-muted">No keys yet</span>';
} else {
} else {
- status = '<span class="pai-state is-muted">Did not answer</span>';
+ status = '<span class="pai-state is-muted">' + name + ' has not published settings yet</span>';
}
}
return '<div class="pai-vault-row"><span class="pai-vault-name">' + name + '</span>' + status + hint + '</div>';
return '<div class="pai-vault-row"><span class="pai-vault-name">' + name + '</span>' + status + hint + '</div>';
});
});
@@ -5,6 +5,8 @@
@@ -5,6 +5,8 @@
tabLoaders map in admin/boot.js (loaded last). No ES modules.
tabLoaders map in admin/boot.js (loaded last). No ES modules.
═══════════════════════════════════════════════════════════════ */
═══════════════════════════════════════════════════════════════ */
const tabLoaders = {
const tabLoaders = {
+ setup: loadSetup,
+ glossary: loadGlossary,
dashboard: loadDashboard,
dashboard: loadDashboard,
users: loadUsers,
users: loadUsers,
streams: loadStreams,
streams: loadStreams,
@@ -80,7 +80,8 @@ let currentTab = 'dashboard';
@@ -80,7 +80,8 @@ let currentTab = 'dashboard';
// Tabs restricted to the network owner (API keys / money / infrastructure / core settings).
// Tabs restricted to the network owner (API keys / money / infrastructure / core settings).
// NOTE: 'ai' is NOT here — admins may use its analytics/explorer tools; only the
// NOTE: 'ai' is NOT here — admins may use its analytics/explorer tools; only the
// AI configuration form inside it is owner-gated (see loadAi).
// AI configuration form inside it is owner-gated (see loadAi).
-const OWNER_ONLY_TABS = new Set(['deploy', 'domains', 'payments', 'cashouts', 'storage', 'email', 'settings', 'grants', 'eventsops']);
+// 'setup' is the owner's checklist of keys and accounts (js/admin/setup.js); its API answers 403 to anyone else too.
+const OWNER_ONLY_TABS = new Set(['setup', 'deploy', 'domains', 'payments', 'cashouts', 'storage', 'email', 'settings', 'grants', 'eventsops']);
function showTab(id, btn) {
function showTab(id, btn) {
if (!tabLoaders[id]) id = 'dashboard';
if (!tabLoaders[id]) id = 'dashboard';
new file mode 100644
new file mode 100644
@@ -0,0 +1,99 @@
@@ -0,0 +1,99 @@
+/* ═══════════════════════════════════════════════════════════════
+ OpenVibe.Network — admin panel (/admin): the glossary (/admin#glossary).
+ The few words the panel cannot avoid, each in plain words (OWNER-SETUP-AND-ADMIN-CLARITY-DESIGN, 2026-11-07,
+ rule 9). A small "What does this mean?" link next to a word opens its line in place; the whole list is the
+ Glossary tab. Classic script, global scope: it relies on admin/core.js's esc() and is loaded before ai.js and
+ setup.js. No ES modules.
+ ═══════════════════════════════════════════════════════════════ */
+const GLOSSARY = [
+ { id: 'credits', word: 'Credits', text: 'What people spend on AI at OpenVibe. People buy them with money, a request uses some, and what a request costs depends on the model. Credits are kept by OpenVibe.Billing, not by the AI providers.' },
+ { id: 'byok', word: 'Their own key (BYOK)', text: '"Bring your own key": a person enters their own key for a provider, and their requests are paid by that key instead of by OpenVibe or by Credits.' },
+ { id: 'resale', word: 'Resale', text: 'Letting people use a provider\'s models through OpenVibe and pay OpenVibe for it. Some providers\' terms forbid this; a provider whose terms are not checked yet is treated as "Do not sell".' },
+ { id: 'cache', word: 'Cache', text: 'A saved copy of an earlier answer or of the start of a long prompt. Reading from the cache is cheaper and faster than asking again, so providers price it lower.' },
+ { id: 'quota', word: 'Quota', text: 'A limit on how much someone may use in a period, for example requests per day. When it is reached, more requests wait or are refused until the period ends.' },
+ { id: 'workflow', word: 'Workflow (saved AI task)', text: 'A saved AI task with its steps and settings, which other parts of OpenVibe run by name. A prompt template is the text it starts from; a routing rule decides which model answers.' },
+ { id: 'lock-key', word: 'Lock key', text: 'The key a service locks every stored key with (its "KEK", in the server\'s settings file). Without it, keys are stored unlocked. Nobody can read a locked key back, not even the owner.' },
+ { id: 'settings-file', word: 'Server\'s settings file', text: 'The private file on the server that holds a service\'s settings (its env file). A setting it holds wins over what is entered here, and can only be changed on the server.' },
+ { id: 'fresh-sign-in', word: 'Confirm it\'s you', text: 'Changing a key or a money setting needs a sign-in from the last ten minutes. If yours is older, OpenVibe asks for your password again and brings you back.' },
+ { id: 'health', word: 'Health (paused provider)', text: 'After several errors in a row OpenVibe pauses a provider for a short while and uses the next one, then tries it again. "Paused" is that wait; "Testing again" is the first try after it.' },
+ { id: 'tokens', word: 'Million words (tokens)', text: 'Providers price text per million tokens; a token is about three quarters of a word. "Read" (input) is what is sent to the model, "written" (output) is what it answers.' },
+ { id: 'version', word: 'Saved version', text: 'Every change to a group of settings is kept as a numbered version, with who made it and why. History shows them; Roll back makes an older one current again, as a new version.' },
+ { id: 'setup-states', word: 'Needed, Not working, Connected, Optional', text: 'On Setup: Needed means something waits on it; Not working means it is set but its last test failed; Connected means it is set and not failing; Optional means nothing waits on it.' },
+];
+
+function glossaryEntry(id) {
+ return GLOSSARY.find((g) => g.id === id) || null;
+}
+
+/** 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>';
+}
+
+function glossaryPopup(id, anchor) {
+ const entry = glossaryEntry(id);
+ if (!entry) return;
+ const dlg = document.createElement('dialog');
+ dlg.className = 'pai-dialog ov-what-dialog';
+ const form = document.createElement('div');
+ form.className = 'pai-form';
+ const h = document.createElement('h3');
+ h.textContent = entry.word;
+ const p = document.createElement('p');
+ p.className = 'ov-what-text';
+ 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);
+ form.append(h, p, actions);
+ dlg.append(form);
+ document.body.appendChild(dlg);
+ dlg.addEventListener('close', () => { dlg.remove(); if (anchor && anchor.focus) anchor.focus(); });
+ dlg.showModal();
+}
+
+/** 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]');
+ if (!link || ev.button !== 0 || ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return;
+ 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);
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,355 @@
@@ -0,0 +1,355 @@
+/* ═══════════════════════════════════════════════════════════════
+ OpenVibe.Network — admin panel (/admin): the owner's Setup tab (/admin#setup).
+ One checklist of everything only the owner can give the network (OWNER-SETUP-AND-ADMIN-CLARITY-DESIGN, 2026-11-07,
+ section 1). The rows come from GET /api/admin/setup (server/admin/setup.js); saving, removing and testing a key go
+ through the owner settings proxy (/api/admin/ai/config, server/admin/ai-config-proxy.js), with its fresh sign-in,
+ vault, audit and alerts. A key is only ever written: its input is built as a DOM node, cleared and removed after
+ saving, and never filled from anything the server sends.
+ Classic script, global scope: it relies on admin/core.js (api, esc, toast, timeAgo, currentUser), glossary.js
+ (glossaryLink) and ai.js (paiConfirm, paiUsd, paiParseUsd). No ES modules.
+ ═══════════════════════════════════════════════════════════════ */
+const SETUP = { data: null, loading: false };
+const SETUP_STATE = {
+ needed: { label: 'Needed', tone: 'bad' },
+ not_working: { label: 'Not working', tone: 'warn' },
+ connected: { label: 'Connected', tone: 'ok' },
+ optional: { label: 'Optional', tone: 'muted' },
+};
+const SETUP_SIGN_IN = '/login?reauth=1&return=%2Fadmin%23setup';
+
+function setupPlural(n, one, many) { return n + ' ' + (n === 1 ? one : many); }
+
+/** "6 things need you · 2 not working · 31 connected · 4 optional" */
+function setupSummary(c) {
+ const parts = [
+ c.needed === 1 ? '1 thing needs you' : c.needed + ' things need you',
+ c.not_working + ' not working',
+ c.connected + ' connected',
+ ];
+ if (c.optional) parts.push(c.optional + ' optional');
+ return parts.join(' · ');
+}
+
+/** "Card payments" → "card payments", "GPUs" stays: the feature after "Needed for". */
+function setupFor(text) {
+ const t = String(text || '');
+ return /^[A-Z][a-z]/.test(t) ? t.charAt(0).toLowerCase() + t.slice(1) : t;
+}
+
+function setupWhen(iso) {
+ if (!iso) return '';
+ try { return timeAgo(iso); } catch { return ''; }
+}
+
+/** The state line in words: what waits, what failed, what is set. */
+function setupStateLine(row) {
+ if (row.kind === 'task') {
+ if (row.done) return 'Done' + (row.done.at ? ' on ' + row.done.at : '') + (row.done.by ? ' by @' + row.done.by : '') + '.';
+ return row.required_for ? 'Needed for ' + setupFor(row.required_for) + '.' : 'Nothing waits on it; do it when you can.';
+ }
+ if (row.state === 'needed') return 'Needed for ' + setupFor(row.required_for) + '.';
+ if (row.state === 'optional') return 'Not set. Nothing waits on it.';
+ const parts = [];
+ if (row.pinned) parts.push('From the server\'s settings file' + (row.pin_env ? ' (' + row.pin_env + ')' : ''));
+ else if (row.secret) parts.push('Entered here, locked' + (row.hint ? ' · ••••' + row.hint : '') + (row.set_at ? ' · set ' + row.set_at : ''));
+ else if (typeof row.value === 'boolean') parts.push(row.value ? 'On' : 'Off');
+ else if (row.value !== null && row.value !== undefined) parts.push('Set to ' + (row.unit === 'nano_usd' ? '$' + paiUsd(row.value) : String(row.value)));
+ else parts.push('Set');
+ if (row.state === 'not_working' && row.last_test) parts.push('the last test failed ' + setupWhen(row.last_test.at) + ': ' + row.last_test.message);
+ else if (row.last_test && row.last_test.passed) parts.push('the last test passed ' + setupWhen(row.last_test.at));
+ else if (row.testable) parts.push('not tested yet');
+ return parts.join('; ') + '.';
+}
+
+async function loadSetup() {
+ const root = document.getElementById('setup-content');
+ if (!root) return;
+ if (!currentUser || !currentUser.is_owner) { root.innerHTML = ''; return; }
+ if (SETUP.loading) return;
+ SETUP.loading = true;
+ if (!SETUP.data) root.innerHTML = '<div class="loading">Asking every service what it needs from you…</div>';
+ try {
+ SETUP.data = await api('/api/admin/setup');
+ setupRender(root);
+ } catch (err) {
+ root.innerHTML = setupHead('') + '<p class="pai-error">Setup could not be read: ' + esc(err.message || 'the request failed') + '</p>';
+ } finally {
+ SETUP.loading = false;
+ }
+}
+
+function setupHead(summary) {
+ return '<header class="setup-head">'
+ + '<h2><i class="fa-solid fa-list-check" aria-hidden="true"></i> Setup</h2>'
+ + '<p class="muted">Everything only you can give OpenVibe: keys, accounts, limits and a few registrations, from every part of the network. Come here when something says it needs you, or when you change a key or an account.</p>'
+ + (summary ? '<p class="setup-summary" role="status">' + summary + '</p>' : '')
+ + '<p class="setup-help muted">Keys are written once and locked; nothing here can show one again. ' + glossaryLink('setup-states', 'What do Needed, Not working, Connected and Optional mean?') + '</p>'
+ + '<div id="setup-fresh" class="pai-fresh" hidden><i class="fa-solid fa-lock" aria-hidden="true"></i> <span>Confirm it\'s you to change keys and money settings.</span> <a class="btn btn-primary" href="' + SETUP_SIGN_IN + '">Sign in again</a> ' + glossaryLink('fresh-sign-in') + '</div>'
+ + '</header>';
+}
+
+function setupRender(root) {
+ const d = SETUP.data;
+ const c = d.counts;
+ const summary = '<strong>' + esc(c.needed === 1 ? '1 thing needs you' : c.needed + ' things need you') + '</strong> · ' + esc(setupSummary(c).split(' · ').slice(1).join(' · '));
+ const groups = d.groups.map((g) => {
+ const counts = [];
+ if (g.counts.needed) counts.push('<span class="setup-count is-bad">' + setupPlural(g.counts.needed, 'needs you', 'need you') + '</span>');
+ if (g.counts.not_working) counts.push('<span class="setup-count is-warn">' + g.counts.not_working + ' not working</span>');
+ return '<section class="setup-group" id="setup-group-' + esc(g.id) + '" aria-labelledby="setup-group-h-' + esc(g.id) + '">'
+ + '<h3 id="setup-group-h-' + esc(g.id) + '">' + esc(g.label) + ' ' + counts.join(' ') + '</h3>'
+ + '<ol class="setup-rows">' + g.rows.map(setupRowHtml).join('') + '</ol></section>';
+ }).join('');
+ const empty = d.empty_groups && d.empty_groups.length
+ ? '<p class="setup-empty muted">Nothing listed yet for ' + esc(d.empty_groups.map((g) => g.label).join(', ')) + ': the services that hold these have not published their settings yet.</p>'
+ : '';
+ const quiet = (d.services || []).filter((s) => s.note);
+ const reach = quiet.length
+ ? '<details class="pai-more setup-services"><summary>Services that ask nothing yet (' + quiet.length + ')</summary><ul>' + quiet.map((s) => '<li>' + esc(s.note) + '</li>').join('') + '</ul></details>'
+ : '';
+ root.innerHTML = setupHead(summary)
+ + (d.rows.length ? groups : '<p class="muted">No service asks anything of you yet. When one publishes its settings, its keys appear here.</p>')
+ + empty + reach
+ + '<p class="setup-foot muted">Network\'s own email and Discord keys are on the <a href="/admin/settings">Settings</a> tab; the AI providers\' keys are also on the <a href="/admin/ai">AI</a> tab.</p>';
+ for (const li of root.querySelectorAll('[data-setup-row]')) setupAttachControls(li);
+ root.onclick = setupClick;
+ if (window.OVForms && window.OVForms.enhance) window.OVForms.enhance(root);
+}
+
+function setupRowHtml(row) {
+ const st = SETUP_STATE[row.state] || SETUP_STATE.optional;
+ // A task is Done; a key or account is Connected; a plain setting (a switch, a cap, an id) is only Set: a switch
+ // that is Off is not "connected" to anything.
+ const stateLabel = row.state !== 'connected' ? st.label : row.kind === 'task' ? 'Done' : row.secret ? st.label : 'Set';
+ const where = row.get_it_at
+ ? '<p class="setup-where"><a href="' + esc(row.get_it_at) + '" target="_blank" rel="noopener noreferrer">' + (row.kind === 'task' ? 'Where to do it' : 'Where to get it') + ' <i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i></a>' + (row.get_it_how ? ' <span>' + esc(row.get_it_how) + '</span>' : '') + '</p>'
+ : (row.get_it_how ? '<p class="setup-where"><span>' + esc(row.get_it_how) + '</span></p>' : '');
+ const what = row.description || row.unlocks || '';
+ const from = row.kind === 'task'
+ ? 'Kept by OpenVibe.Network'
+ : esc(row.service_name) + ' <code>' + esc(row.namespace) + '</code> <code>' + esc(row.key) + '</code>';
+ return '<li class="setup-row is-' + esc(row.state) + '" data-setup-row data-id="' + esc(row.id) + '">'
+ + '<div class="setup-row-main">'
+ + '<div class="setup-row-title"><strong>' + esc(row.title) + '</strong> <span class="setup-state is-' + st.tone + '">' + esc(stateLabel) + '</span>'
+ + (row.danger ? ' <span class="pai-chip-danger">Money</span>' : '') + '</div>'
+ + (what ? '<p class="setup-what">' + esc(what) + '</p>' : '')
+ + '<p class="setup-state-line is-' + st.tone + '">' + esc(setupStateLine(row)) + '</p>'
+ + where
+ + '<p class="setup-from">' + from + '</p>'
+ + '</div>'
+ + '<div class="setup-row-actions" data-setup-actions></div>'
+ + '<p class="setup-result" data-setup-result role="status" hidden></p>'
+ + '</li>';
+}
+
+function setupRowOf(el) {
+ const li = el.closest('[data-setup-row]');
+ const row = li && SETUP.data ? SETUP.data.rows.find((r) => r.id === li.dataset.id) : null;
+ return { li, row };
+}
+
+function setupButton(text, act, cls) {
+ const b = document.createElement('button');
+ b.type = 'button';
+ b.className = 'btn ' + (cls || 'pai-quiet');
+ b.dataset.act = act;
+ b.textContent = text;
+ return b;
+}
+
+/** The input and buttons of one row, as DOM nodes: a key's input is never part of an HTML string. */
+function setupAttachControls(li) {
+ const { row } = setupRowOf(li);
+ const box = li.querySelector('[data-setup-actions]');
+ if (!row || !box) return;
+ box.textContent = '';
+ if (row.kind === 'task') {
+ box.append(row.done ? setupButton('Mark not done', 'undo') : setupButton('Mark done', 'done', 'btn-primary'));
+ return;
+ }
+ if (row.pinned) {
+ const note = document.createElement('p');
+ note.className = 'setup-pinned muted';
+ note.textContent = 'Change it in the server\'s settings file.';
+ box.append(note);
+ if (row.testable && row.set) box.append(setupButton('Test', 'test'));
+ return;
+ }
+ const form = document.createElement('form');
+ form.className = 'setup-input';
+ form.dataset.setupForm = '1';
+ const label = document.createElement('label');
+ const inputId = 'setup-in-' + row.id.replace(/[^a-z0-9]/gi, '-');
+ label.htmlFor = inputId;
+ label.className = 'setup-input-label';
+ const input = document.createElement('input');
+ input.id = inputId;
+ input.autocomplete = 'off';
+ input.spellcheck = false;
+ if (row.secret) {
+ input.type = 'password';
+ label.textContent = row.set ? 'Replace it (write-only)' : 'Paste it here (write-only)';
+ input.placeholder = row.set ? 'A new value replaces the old one' : 'Paste the value';
+ } else if (row.type === 'boolean') {
+ input.type = 'checkbox';
+ input.checked = row.value === true;
+ label.textContent = 'On';
+ } else if (row.unit === 'nano_usd') {
+ input.type = 'text';
+ input.inputMode = 'decimal';
+ label.textContent = 'Amount in dollars';
+ input.value = row.value != null ? paiUsd(row.value) : '';
+ input.placeholder = '0.00';
+ } else if (row.type === 'integer' || row.type === 'number') {
+ input.type = 'number';
+ input.step = row.type === 'integer' ? '1' : 'any';
+ label.textContent = 'Value' + (row.unit ? ' (' + ({ ms: 'milliseconds', seconds: 'seconds', bps: 'bits per second' }[row.unit] || row.unit) + ')' : '');
+ input.value = row.value != null ? String(row.value) : '';
+ } else {
+ input.type = 'text';
+ label.textContent = row.set ? 'Change it' : 'Enter it here';
+ input.value = row.value != null ? String(row.value) : '';
+ if (row.example != null) input.placeholder = String(row.example);
+ }
+ input.dataset.setupInput = '1';
+ const save = setupButton(row.set ? 'Save' : 'Save', 'save', 'btn-primary');
+ save.type = 'submit';
+ form.append(label, input, save);
+ form.addEventListener('submit', (ev) => { ev.preventDefault(); setupSave(li, row, input, save); });
+ box.append(form);
+ const more = document.createElement('div');
+ more.className = 'setup-more-actions';
+ if (row.testable && row.set) more.append(setupButton('Test', 'test'));
+ if (row.set) more.append(setupButton('Remove', 'remove', 'pai-quiet pai-quiet-danger'));
+ if (more.childNodes.length) box.append(more);
+}
+
+function setupFresh(err) {
+ if (!(err && err.status === 403 && err.body && err.body.code === 'config.fresh_required')) return false;
+ const el = document.getElementById('setup-fresh');
+ if (el) {
+ const link = el.querySelector('a.btn');
+ // The proxy's link returns to the AI tab; Setup's returns here.
+ if (link && err.body.sign_in_url) link.href = String(err.body.sign_in_url).replace(/%2Fadmin%23ai$/i, '%2Fadmin%23setup');
+ el.hidden = false;
+ el.scrollIntoView({ block: 'nearest' });
+ }
+ return true;
+}
+
+function setupShowResult(li, text, tone) {
+ const p = li.querySelector('[data-setup-result]');
+ if (!p) return;
+ p.textContent = text;
+ p.className = 'setup-result is-' + tone;
+ p.hidden = false;
+}
+
+const setupConfigPath = (row) => '/api/admin/ai/config/' + encodeURIComponent(row.service) + '/' + encodeURIComponent(row.namespace);
+
+async function setupSave(li, row, input, button) {
+ const isSecret = input.type === 'password';
+ let body;
+ if (isSecret) {
+ if (!input.value) { input.focus(); return; }
+ } else if (input.type === 'checkbox') {
+ body = { values: { [row.key]: input.checked }, merge: true, reason: 'Set on Setup' };
+ } else if (row.unit === 'nano_usd') {
+ const parsed = paiParseUsd(input.value);
+ if (parsed.error) return setupShowResult(li, parsed.error, 'bad');
+ body = { values: { [row.key]: parsed.nano }, merge: true, reason: 'Set on Setup' };
+ } else if (input.type === 'number') {
+ const n = Number(input.value);
+ if (input.value === '' || !Number.isFinite(n) || (row.type === 'integer' && !Number.isInteger(n))) return setupShowResult(li, row.type === 'integer' ? 'Enter a whole number.' : 'Enter a number.', 'bad');
+ body = { values: { [row.key]: n }, merge: true, reason: 'Set on Setup' };
+ } else {
+ if (!input.value.trim()) { input.focus(); return; }
+ body = { values: { [row.key]: input.value.trim() }, merge: true, reason: 'Set on Setup' };
+ }
+ if (row.danger) {
+ const ok = await paiConfirm('Save ' + row.title + '?', [(row.unlocks || row.description || 'This takes effect at once.'), 'It takes effect at once. To undo it, change it back here or roll back its version on the AI tab.']);
+ if (!ok) { if (isSecret) input.value = ''; return; }
+ }
+ button.disabled = true;
+ const was = button.textContent;
+ button.textContent = 'Saving…';
+ try {
+ if (isSecret) {
+ const value = input.value;
+ input.value = '';
+ await api(setupConfigPath(row) + '/secrets/' + encodeURIComponent(row.key), { method: 'PUT', body: { value, reason: 'Entered on Setup' } });
+ } else {
+ await api(setupConfigPath(row), { method: 'POST', body });
+ }
+ toast(row.title + ' saved', 'success');
+ await loadSetup();
+ } catch (err) {
+ if (!setupFresh(err)) setupShowResult(li, err.message || 'It was not saved.', 'bad');
+ button.disabled = false;
+ button.textContent = was;
+ } finally {
+ // A key must not stay in the page, whatever happened.
+ if (isSecret) input.value = '';
+ }
+}
+
+async function setupClick(ev) {
+ const button = ev.target.closest('button[data-act]');
+ if (!button || button.type === 'submit') return;
+ const { li, row } = setupRowOf(button);
+ if (!row) return;
+ const act = button.dataset.act;
+ if (act === 'test') return setupTest(li, row, button);
+ if (act === 'remove') return setupRemove(li, row, button);
+ if (act === 'done' || act === 'undo') return setupTask(li, row, button, act === 'done');
+}
+
+async function setupTest(li, row, button) {
+ button.disabled = true;
+ button.textContent = 'Testing…';
+ setupShowResult(li, 'Asking ' + row.service_name + ' to try it…', 'muted');
+ try {
+ const r = await api(setupConfigPath(row) + '/test/' + encodeURIComponent(row.key), { method: 'POST', body: {} });
+ setupShowResult(li, (r.passed ? 'It works: ' : 'It did not work: ') + r.message, r.passed ? 'ok' : 'bad');
+ // Keep the answer on screen while the row's state catches up.
+ const keep = li.querySelector('[data-setup-result]').textContent;
+ await loadSetup();
+ const again = document.querySelector('[data-setup-row][data-id="' + CSS.escape(row.id) + '"]');
+ if (again) setupShowResult(again, keep, r.passed ? 'ok' : 'bad');
+ } catch (err) {
+ const msg = err && err.body && err.body.code === 'config.test_not_available'
+ ? row.service_name + ' cannot test this yet.'
+ : (err.message || 'The test did not run.');
+ setupShowResult(li, msg, 'bad');
+ button.disabled = false;
+ button.textContent = 'Test';
+ }
+}
+
+async function setupRemove(li, row, button) {
+ const stops = row.required_for ? row.required_for + ' stops working until a new value is entered.' : 'Nothing waits on it; it can be entered again at any time.';
+ const ok = await paiConfirm('Remove ' + row.title + '?', [stops, row.secret ? 'The key is deleted from ' + row.service_name + '. It cannot be shown again, so keep a copy where you got it.' : 'Its earlier value stays in the history, where it can be rolled back.']);
+ if (!ok) return;
+ button.disabled = true;
+ try {
+ if (row.secret) await api(setupConfigPath(row) + '/secrets/' + encodeURIComponent(row.key), { method: 'DELETE', body: { reason: 'Removed on Setup' } });
+ else await api(setupConfigPath(row), { method: 'POST', body: { values: {}, unset: [row.key], merge: true, reason: 'Removed on Setup' } });
+ toast(row.title + ' removed', 'success');
+ await loadSetup();
+ } catch (err) {
+ if (!setupFresh(err)) setupShowResult(li, err.message || 'It was not removed.', 'bad');
+ button.disabled = false;
+ }
+}
+
+async function setupTask(li, row, button, done) {
+ button.disabled = true;
+ try {
+ await api('/api/admin/setup/tasks/' + encodeURIComponent(row.key), { method: 'POST', body: { done } });
+ toast(done ? row.title + ': done' : row.title + ': not done', 'success');
+ await loadSetup();
+ } catch (err) {
+ setupShowResult(li, err.message || 'It was not saved.', 'bad');
+ button.disabled = false;
+ }
+}
@@ -12,6 +12,7 @@
@@ -12,6 +12,7 @@
* POST /api/admin/ai/config/:service/:namespace/rollback roll back to a revision
* POST /api/admin/ai/config/:service/:namespace/rollback roll back to a revision
* PUT /api/admin/ai/config/:service/:namespace/secrets/:key set a secret (common.config-secret-put@1)
* PUT /api/admin/ai/config/:service/:namespace/secrets/:key set a secret (common.config-secret-put@1)
* DELETE /api/admin/ai/config/:service/:namespace/secrets/:key remove a secret
* DELETE /api/admin/ai/config/:service/:namespace/secrets/:key remove a secret
+ * POST /api/admin/ai/config/:service/:namespace/test/:key the service tries the stored value (Setup's Test)
* GET|POST /api/admin/ai/config/providers AI's provider registry (ai.provider.manage)
* GET|POST /api/admin/ai/config/providers AI's provider registry (ai.provider.manage)
* PATCH /api/admin/ai/config/providers/:key one provider
* PATCH /api/admin/ai/config/providers/:key one provider
* PUT|DELETE /api/admin/ai/config/providers/:key/key its write-only key (bound to the provider origin)
* PUT|DELETE /api/admin/ai/config/providers/:key/key its write-only key (bound to the provider origin)
@@ -25,10 +26,14 @@
@@ -25,10 +26,14 @@
*
*
* Network keeps no value: the body is forwarded and dropped, never written to a table or a log, and the audit row
* Network keeps no value: the body is forwarded and dropped, never written to a table or a log, and the audit row
* names only the service, namespace, key, action and revision. Every secret write and every danger change raises an
* names only the service, namespace, key, action and revision. Every secret write and every danger change raises an
- * operator alert, again with names and never a value.
+ * operator alert, again with names and never a value. A test keeps its outcome as an ai_config_test audit row (the
+ * service's one-line answer, which openvibe-shared scrubs of every secret, and a one-way digest of what it tried),
+ * so the owner's Setup tab (server/admin/setup.js) can say "Not working" after a reload; the Audit tab redacts it
+ * for anyone but the owner, like every config row.
*
*
* test/ai-config-proxy.test.js
* test/ai-config-proxy.test.js
*/
*/
+const crypto = require('crypto');
const express = require('express');
const express = require('express');
const rateLimit = require('express-rate-limit');
const rateLimit = require('express-rate-limit');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
@@ -37,12 +42,20 @@ const { reauthUrl } = require('../auth/reauth');
@@ -37,12 +42,20 @@ const { reauthUrl } = require('../auth/reauth');
const alerts = require('../operator/alerts');
const alerts = require('../operator/alerts');
const { isFreshSignIn } = require('../identity/account-merge');
const { isFreshSignIn } = require('../identity/account-merge');
-const SERVICES = ['ai', 'billing', 'services', 'watch', 'bench', 'report', 'host', 'codes', 'actor'];
+// The nine AI Exchange services (D11), then every other service that holds an owner input (the Setup tab,
+// 2026-11-07). A service that has not mounted openvibe-shared/config's routes yet answers 404 or does not answer;
+// the panel says it "has not published settings yet".
+const SERVICES = ['ai', 'billing', 'services', 'watch', 'bench', 'report', 'host', 'codes', 'actor',
+ 'chat', 'connect', 'deals', 'work', 'sources', 'design', 'domains', 'media'];
const LABELS = {
const LABELS = {
ai: 'OpenVibe.AI', billing: 'OpenVibe.Billing', services: 'OpenVibe.Services', watch: 'OpenVibe.Watch',
ai: 'OpenVibe.AI', billing: 'OpenVibe.Billing', services: 'OpenVibe.Services', watch: 'OpenVibe.Watch',
bench: 'OpenVibe.Bench', report: 'OpenVibe.Report', host: 'OpenVibe.Host', codes: 'OpenVibe.Codes',
bench: 'OpenVibe.Bench', report: 'OpenVibe.Report', host: 'OpenVibe.Host', codes: 'OpenVibe.Codes',
- actor: 'OpenVibe.Actor',
+ actor: 'OpenVibe.Actor', chat: 'OpenVibe.Chat', connect: 'OpenVibe Connect', deals: 'OpenVibe.Deals',
+ work: 'OpenVibe.Work', sources: 'OpenVibe.Sources', design: 'OpenVibe.Design', domains: 'OpenVibe.Domains',
+ media: 'OpenVibe.Media',
};
};
+const TEST_TIMEOUT_MS = 20000; // the service gives its tester 15 s; this leaves it time to answer
+const TEST_MESSAGE_MAX = 300;
const FRESH_S = 10 * 60;
const FRESH_S = 10 * 60;
const WRITES_PER_HOUR = 60;
const WRITES_PER_HOUR = 60;
const KEY_RE = /^[A-Za-z0-9_][A-Za-z0-9_.:-]{0,127}$/;
const KEY_RE = /^[A-Za-z0-9_][A-Za-z0-9_.:-]{0,127}$/;
@@ -110,6 +123,41 @@ function redact(value, secret) {
@@ -110,6 +123,41 @@ function redact(value, secret) {
* first 4.110 builds returned and normalised to the contract's array; `kek` and `secrets` are carried beside the
* first 4.110 builds returned and normalised to the contract's array; `kek` and `secrets` are carried beside the
* validated summary (the tab's vault row needs the KEK id) but are not part of it.
* validated summary (the tab's vault row needs the KEK id) but are not part of it.
*/
*/
+/** The four-character hint, the date and the fingerprint of each secret: what Setup shows ("••••a1b2 · set 3 Nov"). */
+function secretMarkers(secrets) {
+ if (!secrets || typeof secrets !== 'object' || Array.isArray(secrets)) return {};
+ const out = {};
+ for (const [key, m] of Object.entries(secrets)) {
+ if (!KEY_RE.test(key) || !m || typeof m !== 'object') continue;
+ out[key] = {
+ hint: typeof m.hint === 'string' && m.hint.length <= 4 ? m.hint : null,
+ set_at: typeof m.set_at === 'string' ? m.set_at.slice(0, 40) : null,
+ fingerprint: typeof m.fingerprint === 'string' && /^[0-9a-f]{64}$/.test(m.fingerprint) ? m.fingerprint : null,
+ bound_to: typeof m.bound_to === 'string' ? m.bound_to.slice(0, 200) : null,
+ };
+ }
+ return out;
+}
+
+/**
+ * A one-way digest of the value a test tried: of the secret's marker fingerprint, or of a plain value. A later change
+ * to that key gives another digest, so an old failure stops counting; the digest is neither the value nor its marker.
+ */
+function triedDigest(service, namespace, key, value, marker) {
+ const fp = (marker && marker.fingerprint) || (value && typeof value === 'object' && value.redacted === true ? value.fingerprint : null);
+ const what = fp ? `secret:${fp}` : `value:${JSON.stringify(value === undefined ? null : value)}`;
+ return crypto.createHash('sha256').update(`ov-setup-test|${service}|${namespace}|${key}|${what}`).digest('hex').slice(0, 32);
+}
+
+/** What a namespace answer carries beside the validated summary: env names of pins and the secrets' markers. */
+function extrasOf(extra) {
+ const out = {};
+ if (extra.pin_env) out.pin_env = extra.pin_env;
+ const markers = secretMarkers(extra.secrets);
+ if (Object.keys(markers).length) out.secret_markers = markers;
+ return out;
+}
+
function normaliseSummary(raw) {
function normaliseSummary(raw) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { summary: raw, extra: {} };
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { summary: raw, extra: {} };
const summary = { ...raw };
const summary = { ...raw };
@@ -125,27 +173,13 @@ function normaliseSummary(raw) {
@@ -125,27 +173,13 @@ function normaliseSummary(raw) {
return { summary, extra };
return { summary, extra };
}
}
-function createAiConfigProxy(opts = {}) {
- const r = express.Router();
+/**
+ * The one outbound call site of the owner settings (the proxy below and the Setup tab, server/admin/setup.js): a
+ * service internal URL from operator config, a fixed /internal or /api path, Network's own token for openvibe.<service>
+ * and the owner's usr_ id in X-OV-Subject.
+ */
+function createServiceCaller(opts = {}) {
const log = opts.log || console;
const log = opts.log || console;
- const now = opts.now || (() => Date.now());
- const writesPerHour = opts.writesPerHour || Number(process.env.AI_CONFIG_WRITES_PER_HOUR) || WRITES_PER_HOUR;
-
- // Owner only on every route, even if a caller mounts this router without its own guard.
- r.use(requireOwner);
-
- // 60 writes an hour for the owner: a person cannot accidentally (or maliciously) rewrite the platform's settings
- // faster than that. Reads are not limited; the response says how long to wait.
- r.use(rateLimit({
- windowMs: 60 * 60 * 1000,
- limit: writesPerHour,
- standardHeaders: true,
- legacyHeaders: false,
- skip: (req) => !['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method),
- keyGenerator: (req) => `ai-config:${req.user && req.user.id != null ? req.user.id : req.ip}`,
- handler: (_req, res) => send(res, 429, { ok: false, error: 'Too many settings changes this hour. Try again later.', code: 'config.rate_limited' }),
- }));
-
const state = (req) => ({
const state = (req) => ({
db: opts.db || req.app.locals.db,
db: opts.db || req.app.locals.db,
services: opts.services || (req.app.locals.config && req.app.locals.config.services) || {},
services: opts.services || (req.app.locals.config && req.app.locals.config.services) || {},
@@ -154,7 +188,6 @@ function createAiConfigProxy(opts = {}) {
@@ -154,7 +188,6 @@ function createAiConfigProxy(opts = {}) {
config: opts.config || req.app.locals.config || {},
config: opts.config || req.app.locals.config || {},
});
});
- /** The one outbound call site: a service internal URL from operator config, a fixed /internal or /api path. */
async function callService(req, service, pathName, { method = 'GET', body = null, cap = `${service}.config.manage`, fresh = false, secret = null, timeout = 8000 } = {}) {
async function callService(req, service, pathName, { method = 'GET', body = null, cap = `${service}.config.manage`, fresh = false, secret = null, timeout = 8000 } = {}) {
const st = state(req);
const st = state(req);
const entry = st.services && st.services[service];
const entry = st.services && st.services[service];
@@ -192,6 +225,62 @@ function createAiConfigProxy(opts = {}) {
@@ -192,6 +225,62 @@ function createAiConfigProxy(opts = {}) {
return { status: upstream.status, answer: secret ? redact(answer, secret) : answer };
return { status: upstream.status, answer: secret ? redact(answer, secret) : answer };
}
}
+ /**
+ * Every namespace of one service, validated as common.config-namespace@1: { answered, namespaces, warnings, vault }.
+ * A service that is not reachable, or has no /internal/config yet, is { answered: false, status, detail }.
+ */
+ async function serviceNamespaces(req, service, { timeout = 8000 } = {}) {
+ let out;
+ try { out = await callService(req, service, '/internal/config', { timeout }); } catch (err) {
+ if (err instanceof ProxyError) return { answered: false, status: err.status, code: err.code, detail: err.message };
+ throw err;
+ }
+ if (out.status < 200 || out.status >= 300) {
+ const a = out.answer && typeof out.answer === 'object' ? out.answer : {};
+ return { answered: false, status: out.status, code: a.code || 'config.service_refused', detail: a.detail || a.error || `${label(service)} answered ${out.status}` };
+ }
+ const raw = out.answer && Array.isArray(out.answer.namespaces) ? out.answer.namespaces : [];
+ const namespaces = [];
+ const warnings = [];
+ let vault = null;
+ for (const item of raw) {
+ const { summary, extra } = normaliseSummary(item);
+ const checked = contracts.validate('common.config-namespace@1', summary);
+ if (!checked.valid || summary.service !== service) {
+ warnings.push({ service, namespace: summary && typeof summary.namespace === 'string' ? summary.namespace : null, reason: 'invalid-summary', detail: checked.valid ? `the summary is for ${summary.service}` : checked.errors.slice(0, 4).map((e) => `${e.path} ${e.message}`).join('; ') });
+ continue;
+ }
+ namespaces.push({ ...summary, ...extrasOf(extra) });
+ if (vault === null && typeof summary.sealed === 'boolean') vault = { sealed: summary.sealed, kek: extra.kek !== undefined ? extra.kek : null };
+ }
+ return { answered: true, namespaces, warnings, vault: vault || { sealed: null, kek: null } };
+ }
+
+ return { state, callService, serviceNamespaces };
+}
+
+function createAiConfigProxy(opts = {}) {
+ const r = express.Router();
+ const log = opts.log || console;
+ const now = opts.now || (() => Date.now());
+ const writesPerHour = opts.writesPerHour || Number(process.env.AI_CONFIG_WRITES_PER_HOUR) || WRITES_PER_HOUR;
+ const { state, callService, serviceNamespaces } = createServiceCaller(opts);
+
+ // Owner only on every route, even if a caller mounts this router without its own guard.
+ r.use(requireOwner);
+
+ // 60 writes an hour for the owner: a person cannot accidentally (or maliciously) rewrite the platform's settings
+ // faster than that. Reads are not limited; the response says how long to wait.
+ r.use(rateLimit({
+ windowMs: 60 * 60 * 1000,
+ limit: writesPerHour,
+ standardHeaders: true,
+ legacyHeaders: false,
+ skip: (req) => !['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method),
+ keyGenerator: (req) => `ai-config:${req.user && req.user.id != null ? req.user.id : req.ip}`,
+ handler: (_req, res) => send(res, 429, { ok: false, error: 'Too many settings changes this hour. Try again later.', code: 'config.rate_limited' }),
+ }));
+
function send(res, status, body) {
function send(res, status, body) {
res.set('Cache-Control', 'no-store');
res.set('Cache-Control', 'no-store');
return res.status(status).json(body);
return res.status(status).json(body);
@@ -225,6 +314,18 @@ function createAiConfigProxy(opts = {}) {
@@ -225,6 +314,18 @@ function createAiConfigProxy(opts = {}) {
}
}
}
}
+ /** A test's outcome, for the Setup tab: names, the one-line answer and what was tried; never a value. */
+ async function recordTest(req, details) {
+ const st = state(req);
+ if (!st.db || !req.user) return;
+ try {
+ await st.db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)')
+ .run(req.user.id, 'ai_config_test', JSON.stringify(details));
+ } catch (err) {
+ log.warn(`[AiConfig] test row failed: ${err.message}`);
+ }
+ }
+
/**
/**
* One operator alert per change, for the owner and OPERATOR_ALERT_USERNAMES: the same notification type Host's
* One operator alert per change, for the owner and OPERATOR_ALERT_USERNAMES: the same notification type Host's
* alerts use, so it reaches the bell and web push and no block hides it. It names the service, namespace, key,
* alerts use, so it reaches the bell and web push and no block hides it. It names the service, namespace, key,
@@ -354,23 +455,11 @@ function createAiConfigProxy(opts = {}) {
@@ -354,23 +455,11 @@ function createAiConfigProxy(opts = {}) {
async function listNamespaces(req, res) {
async function listNamespaces(req, res) {
try {
try {
const service = serviceOf(req);
const service = serviceOf(req);
- const out = await callService(req, service, '/internal/config');
- if (out.status < 200 || out.status >= 300) return upstreamFailed(res, service, out);
- const raw = out.answer && Array.isArray(out.answer.namespaces) ? out.answer.namespaces : [];
- const namespaces = [];
- const warnings = [];
- let vault = null;
- for (const item of raw) {
- const { summary, extra } = normaliseSummary(item);
- const checked = contracts.validate('common.config-namespace@1', summary);
- if (!checked.valid || summary.service !== service) {
- warnings.push({ service, namespace: summary && typeof summary.namespace === 'string' ? summary.namespace : null, reason: 'invalid-summary', detail: checked.valid ? `the summary is for ${summary.service}` : checked.errors.slice(0, 4).map((e) => `${e.path} ${e.message}`).join('; ') });
- continue;
- }
- namespaces.push({ ...summary, ...(extra.pin_env ? { pin_env: extra.pin_env } : {}) });
- if (vault === null && typeof summary.sealed === 'boolean') vault = { sealed: summary.sealed, kek: extra.kek !== undefined ? extra.kek : null };
+ const got = await serviceNamespaces(req, service);
+ if (!got.answered) {
+ return send(res, got.status >= 400 ? got.status : 502, { ok: false, error: got.detail, code: got.code });
}
}
- send(res, 200, { ok: true, service, namespaces, warnings, vault: vault || { sealed: null, kek: null } });
+ send(res, 200, { ok: true, service, namespaces: got.namespaces, warnings: got.warnings, vault: got.vault });
} catch (err) { fail(res, err); }
} catch (err) { fail(res, err); }
}
}
// The service called "services" would collide with GET /services (the list of services) if it were asked for as
// The service called "services" would collide with GET /services (the list of services) if it were asked for as
@@ -393,7 +482,7 @@ function createAiConfigProxy(opts = {}) {
@@ -393,7 +482,7 @@ function createAiConfigProxy(opts = {}) {
vault: { sealed: null, kek: null },
vault: { sealed: null, kek: null },
});
});
}
}
- send(res, 200, { ok: true, service, namespace: { ...summary, ...(extra.pin_env ? { pin_env: extra.pin_env } : {}) }, warnings: [], vault: { sealed: summary.sealed, kek: extra.kek !== undefined ? extra.kek : null } });
+ send(res, 200, { ok: true, service, namespace: { ...summary, ...extrasOf(extra) }, warnings: [], vault: { sealed: summary.sealed, kek: extra.kek !== undefined ? extra.kek : null } });
} catch (err) { fail(res, err); }
} catch (err) { fail(res, err); }
});
});
@@ -464,6 +553,38 @@ function createAiConfigProxy(opts = {}) {
@@ -464,6 +553,38 @@ function createAiConfigProxy(opts = {}) {
} catch (err) { fail(res, err); }
} catch (err) { fail(res, err); }
});
});
+ /**
+ * Setup's Test: the service's tester tries the stored value with the provider (openvibe-shared 4.125.0's test hook)
+ * and answers { ok, message } in one line. The outcome is kept as an audit row with a digest of what was tried
+ * (triedDigest), so a later change to that key makes it stale. No fresh sign-in:
+ * a test changes nothing and shows nothing of the value. It counts against the hourly write allowance.
+ */
+ r.post('/:service/:namespace/test/:key', async (req, res) => {
+ try {
+ const service = serviceOf(req);
+ const namespace = namespaceOf(req, service);
+ const key = keyOf(req);
+ let tried = null;
+ const current = await callService(req, service, `/internal/config/${encodeURIComponent(namespace)}`);
+ if (current.status >= 200 && current.status < 300) {
+ const { summary, extra } = normaliseSummary(current.answer);
+ const value = summary && summary.values ? summary.values[key] : undefined;
+ tried = triedDigest(service, namespace, key, value, secretMarkers(extra.secrets)[key]);
+ }
+ const out = await callService(req, service, `/internal/config/${encodeURIComponent(namespace)}/test/${encodeURIComponent(key)}`, {
+ method: 'POST', body: {}, timeout: TEST_TIMEOUT_MS,
+ });
+ if (out.status < 200 || out.status >= 300) return upstreamFailed(res, service, out);
+ const answer = out.answer && typeof out.answer === 'object' ? out.answer : {};
+ const passed = answer.ok === true;
+ const message = String(answer.message == null ? '' : answer.message).replace(/\s+/g, ' ').trim().slice(0, TEST_MESSAGE_MAX)
+ || (passed ? 'It works.' : 'It did not work.');
+ const testedAt = new Date(now()).toISOString();
+ await recordTest(req, { service, namespace, key, passed, message, tried, tested_at: testedAt });
+ send(res, 200, { ok: true, passed, message, tested_at: testedAt });
+ } catch (err) { fail(res, err); }
+ });
+
r.put('/:service/:namespace/secrets/:key', async (req, res) => {
r.put('/:service/:namespace/secrets/:key', async (req, res) => {
try {
try {
const service = serviceOf(req);
const service = serviceOf(req);
@@ -514,6 +635,6 @@ function createAiConfigProxy(opts = {}) {
@@ -514,6 +635,6 @@ function createAiConfigProxy(opts = {}) {
}
}
module.exports = {
module.exports = {
- createAiConfigProxy, SERVICES, FRESH_S, WRITES_PER_HOUR, signInUrl, isFresh, dangerOf, normaliseSummary,
- sanitizeProvider, redact,
+ createAiConfigProxy, createServiceCaller, SERVICES, LABELS, label, FRESH_S, WRITES_PER_HOUR, signInUrl, isFresh, dangerOf,
+ normaliseSummary, secretMarkers, triedDigest, sanitizeProvider, redact, ProxyError,
};
};
@@ -1037,6 +1037,9 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -1037,6 +1037,9 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
// owner (server/admin/ai-config-proxy.js). Owner only on every route; the proxy re-checks, and a write carries the
// owner (server/admin/ai-config-proxy.js). Owner only on every route; the proxy re-checks, and a write carries the
// owner in X-OV-Subject, with a fresh sign-in for keys and x-danger money settings.
// owner in X-OV-Subject, with a fresh sign-in for keys and x-danger money settings.
router.use('/ai/config', requireOwner, require('./ai-config-proxy').createAiConfigProxy({ db, notificationService }));
router.use('/ai/config', requireOwner, require('./ai-config-proxy').createAiConfigProxy({ db, notificationService }));
+ // The owner's Setup tab (server/admin/setup.js): every key and account only the owner can give, from every service's
+ // config namespaces, plus Network's own to-dos. Owner only; saving goes through the proxy above.
+ router.use('/setup', requireOwner, require('./setup').createSetupRoutes({ db }));
return router;
return router;
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,299 @@
@@ -0,0 +1,299 @@
+'use strict';
+/**
+ * The owner's Setup tab (OWNER-SETUP-AND-ADMIN-CLARITY-DESIGN, 2026-11-07, section 1): one checklist of everything
+ * only the owner can give the network — keys, accounts, caps — and the few things to do elsewhere (legal
+ * registrations, DNSSEC at the registrar, a provider top-up).
+ *
+ * GET /api/admin/setup every row, grouped, Needed first, with the counted summary
+ * POST /api/admin/setup/tasks/:key { done } marks one of Network's own to-dos done (or not done)
+ *
+ * Owner only on every route. Setup holds no value: a key row is a field of the owning service's config namespace
+ * that carries x-owner-input (openvibe-shared 4.125.0), read through the owner settings proxy's caller
+ * (server/admin/ai-config-proxy.js) with Network's token and the owner in X-OV-Subject. Saving, removing and testing
+ * a key go through that proxy's routes, with its fresh sign-in rule, vault, audit and alerts. The state of a row:
+ * Needed unset, and x-required-for names what waits on it
+ * Not working set, and its last test (an ai_config_test audit row for the value set now) failed
+ * Connected set, and not failing (tested and passed, or not tested yet); a to-do marked done
+ * Optional unset, and nothing waits on it
+ * The to-dos are Network's own namespace, network.setup (openvibe-shared/config), so marking one done is a revision
+ * with its actor, history and rollback like any other setting.
+ *
+ * test/setup.test.js
+ */
+const express = require('express');
+const config = require('openvibe-shared/config');
+const { requireOwner } = require('../auth/owner-guard');
+const siteConfig = require('./site-config');
+const { createServiceCaller, SERVICES, label, triedDigest } = require('./ai-config-proxy');
+
+const NAMESPACE = 'network.setup';
+const LIST_TIMEOUT_MS = 4000;
+const TESTS_READ = 1000;
+const STATES = ['needed', 'not_working', 'connected', 'optional'];
+const STATE_RANK = Object.fromEntries(STATES.map((s, i) => [s, i]));
+// The groups and their order come with openvibe-shared (config.OWNER_INPUT_GROUPS, 4.125.0). The same list is here
+// for a Network still pinned to an older release; a row with a group neither knows lands in "Other".
+const GROUPS = config.OWNER_INPUT_GROUPS || Object.freeze({
+ money: 'Money', 'ai-providers': 'AI providers', gpus: 'GPUs', voices: 'Voices', 'chat-apps': 'Chat apps',
+ 'sign-in-apps': 'Sign-in apps', 'data-sources': 'Data sources', 'design-publishing': 'Design and publishing',
+ domains: 'Domains', legal: 'Legal and registrations',
+});
+const GROUP_IDS = Object.keys(GROUPS);
+const KEY_RE = /^[a-z][a-z0-9_]{0,63}$/;
+
+const DONE = Object.freeze({
+ type: ['object', 'null'],
+ additionalProperties: false,
+ properties: {
+ done_at: { type: 'string', maxLength: 40, title: 'Done on', description: 'When the owner marked it done, as an ISO date and time.' },
+ done_by: { type: 'string', maxLength: 80, title: 'Done by', description: 'The username of the person who marked it done (the owner).' },
+ },
+});
+const task = (title, description, ownerInput, requiredFor) => ({
+ ...DONE, title, description, 'x-owner-input': ownerInput, ...(requiredFor ? { 'x-required-for': requiredFor } : {}),
+});
+
+/**
+ * Network's own to-dos: things only the owner can do, somewhere else, that have no key to store. Each value is
+ * { done_at, done_by } once marked done.
+ */
+const TASKS_SCHEMA = Object.freeze({
+ title: 'Owner to-dos',
+ description: 'Things only the owner can do outside OpenVibe, with no key to keep: registrations, records at the registrar and top-ups. Mark one done when it is.',
+ type: 'object',
+ additionalProperties: false,
+ properties: {
+ provider_topup: task('Top up the prepaid AI balances',
+ 'Some AI providers run on a prepaid balance. When one runs out, OpenVibe pauses that provider and uses the next one. Mark it done after a top-up.',
+ { group: 'money', unlocks: 'Keeps the AI providers that run on a prepaid balance answering.', get_it_at: 'https://ai.openvibe.services/console', get_it_how: 'Open Providers in the AI console, see which balance is low, and top it up on that provider\'s own site.' }),
+ dnssec_records: task('Add the DNSSEC records at the registrar',
+ 'Signs OpenVibe\'s domains so nobody can answer for them with false addresses. Cloudflare makes one DS record per domain; it works once the registrar has it.',
+ { group: 'domains', unlocks: 'Protects every OpenVibe domain from forged DNS answers.', get_it_at: 'https://developers.cloudflare.com/dns/dnssec/', get_it_how: 'Turn on DNSSEC for each domain in Cloudflare (DNS, then Settings), then copy its DS record to the registrar.' }),
+ ncmec_registration: task('Register with NCMEC\'s CyberTipline',
+ 'Registers OpenVibe as a service that reports child sexual abuse material to NCMEC, which US law asks of any site people upload to. Mark it done once NCMEC confirms.',
+ { group: 'legal', unlocks: 'Lets OpenVibe report child abuse imagery to the authorities, as the law requires.', get_it_at: 'https://www.missingkids.org/gethelpnow/cybertipline', get_it_how: 'Follow the link for electronic service providers and ask for an ESP account for OpenVibe.' },
+ 'Reporting abuse imagery as US law requires'),
+ csam_scanning: task('Turn on CSAM scanning',
+ 'Cloudflare compares every image OpenVibe serves with known child abuse imagery and tells you when one matches. It needs the NCMEC registration; nothing changes for people.',
+ { group: 'legal', unlocks: 'Finds known child abuse imagery among the pictures OpenVibe serves, so it can be removed and reported.', get_it_at: 'https://developers.cloudflare.com/cache/reference/csam-scanning/', get_it_how: 'In the Cloudflare dashboard open Caching, then Configuration, and turn on the CSAM Scanning Tool with the email NCMEC has.' },
+ 'Finding known abuse imagery'),
+ dmca_agent: task('Name a DMCA agent',
+ 'Registers the person who receives copyright takedown notices with the US Copyright Office. Without it, OpenVibe can lose the protection the law gives it for what people upload.',
+ { group: 'legal', unlocks: 'Keeps OpenVibe protected when someone uploads what they do not own.', get_it_at: 'https://dmca.copyright.gov/osp/', get_it_how: 'Make an account, register OpenVibe as a service provider and name the agent. It costs $6 and is renewed every three years.' },
+ 'Safe harbour for what people upload'),
+ public_suffix_list: task('Add people\'s site domain to the Public Suffix List',
+ 'Tells browsers that each subdomain where a person\'s own site lives belongs to someone different, so one person\'s site can never read another\'s cookies.',
+ { group: 'legal', unlocks: 'Keeps people\'s own sites from reading each other\'s cookies in every browser.', get_it_at: 'https://github.com/publicsuffix/list/wiki/Guidelines', get_it_how: 'Open a pull request to the list\'s private section and add the _psl TXT record it asks for at the registrar.' }),
+ },
+});
+
+const byDb = new WeakMap();
+/** Network's own to-do store (openvibe-shared/config, namespace network.setup), one per database handle. */
+async function tasksStore(db, log = console) {
+ if (byDb.has(db)) return byDb.get(db);
+ const ready = config.createConfigStore({
+ db, service: 'network', namespace: NAMESPACE, schema: TASKS_SCHEMA,
+ log: { info: () => {}, warn: (m) => (log.warn || log.log || (() => {})).call(log, `[Setup] ${m}`), error: (m) => (log.error || log.warn || (() => {})).call(log, `[Setup] ${m}`) },
+ });
+ byDb.set(db, ready);
+ try { return await ready; } catch (err) { byDb.delete(db); throw err; }
+}
+
+const isSet = (value, secret) => {
+ if (secret) return !!(value && typeof value === 'object' && value.redacted === true && typeof value.fingerprint === 'string');
+ if (value === undefined || value === null || value === '') return false;
+ if (Array.isArray(value)) return value.length > 0;
+ if (typeof value === 'object') return Object.keys(value).length > 0;
+ return true;
+};
+const groupOf = (g) => (GROUP_IDS.includes(g) ? g : 'other');
+const groupLabel = (g) => (g === 'other' ? 'Other' : GROUPS[g]);
+const text = (v, max = 500) => (typeof v === 'string' ? v.replace(/\s+/g, ' ').trim().slice(0, max) : '');
+const httpsUrl = (v) => { try { const u = new URL(String(v)); return u.protocol === 'https:' ? u.href : null; } catch { return null; } };
+const day = (iso) => { const t = Date.parse(iso || ''); return Number.isFinite(t) ? new Date(t).toISOString().slice(0, 10) : null; };
+
+/** The latest test of each key (an ai_config_test audit row), newest first wins. */
+async function latestTests(db) {
+ const out = new Map();
+ let rows = [];
+ try {
+ rows = await db.prepare("SELECT details, created_at FROM audit_log WHERE action = 'ai_config_test' ORDER BY id DESC LIMIT ?").all(TESTS_READ);
+ } catch { rows = []; }
+ for (const r of rows) {
+ let d;
+ try { d = JSON.parse(r.details); } catch { continue; }
+ if (!d || typeof d !== 'object') continue;
+ const id = `${d.service}|${d.namespace}|${d.key}`;
+ if (out.has(id)) continue;
+ out.set(id, {
+ passed: d.passed === true, message: text(d.message, 300), at: text(d.tested_at, 40) || (r.created_at ? new Date(r.created_at).toISOString() : null),
+ tried: typeof d.tried === 'string' ? d.tried : null,
+ });
+ }
+ return out;
+}
+
+/**
+ * Whether a key is a secret. The summary classifies every schema property from openvibe-shared 4.125.0; a service on
+ * an older release leaves an unset key out, and then a bound key, a writeOnly one or a key/token/secret/password name
+ * is taken for a secret, so its value goes to the write-only route rather than a plain field.
+ */
+function isSecretKey(ns, key, prop) {
+ const cls = ns.classification ? ns.classification[key] : undefined;
+ if (cls) return cls === 'secret';
+ return typeof prop['x-bind'] === 'string' || prop.writeOnly === true || /(^|[._-])(api_?key|key|token|secret|password)$/i.test(key);
+}
+
+/** One key row from a field with x-owner-input. */
+function keyRow(service, ns, key, prop, tests) {
+ const owner = prop['x-owner-input'];
+ const secret = isSecretKey(ns, key, prop);
+ const pinned = Array.isArray(ns.pins) && ns.pins.includes(key);
+ const pinEnv = pinned && ns.pin_env ? ns.pin_env[key] || null : null;
+ const value = ns.values ? ns.values[key] : undefined;
+ const marker = ns.secret_markers ? ns.secret_markers[key] : null;
+ const set = isSet(value, secret);
+ const types = [].concat(prop.type || 'string');
+ const type = types.find((t) => t !== 'null') || 'string';
+ let test = tests.get(`${service}|${ns.namespace}|${key}`) || null;
+ // A test is about the value it tried: once that key holds another value, its old answer no longer counts.
+ if (test && set && test.tried && test.tried !== triedDigest(service, ns.namespace, key, value, marker)) test = null;
+ if (!set) test = null;
+ const requiredFor = text(prop['x-required-for'], 200) || null;
+ let state;
+ if (!set) state = requiredFor ? 'needed' : 'optional';
+ else if (test && !test.passed) state = 'not_working';
+ else state = 'connected';
+ return {
+ id: `${service}/${ns.namespace}/${key}`, kind: 'key', service, service_name: label(service), namespace: ns.namespace, key,
+ group: groupOf(owner.group), title: text(prop.title, 120) || key, description: text(prop.description, 600),
+ unlocks: text(owner.unlocks, 300), get_it_at: httpsUrl(owner.get_it_at), get_it_how: text(owner.get_it_how, 300) || null,
+ required_for: requiredFor, testable: owner.test === true, secret, type, unit: text(prop['x-unit'], 20) || null,
+ example: secret ? null : (['string', 'number', 'boolean'].includes(typeof prop['x-example']) ? prop['x-example'] : null),
+ danger: prop['x-danger'] === true, pinned, pin_env: pinEnv,
+ set, hint: marker && marker.hint ? marker.hint : null, set_at: day(marker && marker.set_at),
+ value: !secret && set && ['string', 'number', 'boolean'].includes(typeof value) ? value : null,
+ state, last_test: test ? { passed: test.passed, message: test.message, at: test.at } : null,
+ };
+}
+
+/** One of Network's own to-dos. */
+function taskRow(key, prop, value) {
+ const owner = prop['x-owner-input'];
+ const done = value && typeof value === 'object' && value.done_at ? { at: day(value.done_at), by: text(value.done_by, 80) || null } : null;
+ const requiredFor = text(prop['x-required-for'], 200) || null;
+ return {
+ id: `network/${NAMESPACE}/${key}`, kind: 'task', service: 'network', service_name: 'OpenVibe.Network', namespace: NAMESPACE, key,
+ group: groupOf(owner.group), title: prop.title, description: prop.description, unlocks: owner.unlocks,
+ get_it_at: httpsUrl(owner.get_it_at), get_it_how: owner.get_it_how || null, required_for: requiredFor,
+ testable: false, secret: false, set: !!done, done, state: done ? 'connected' : requiredFor ? 'needed' : 'optional', last_test: null,
+ };
+}
+
+const byState = (a, b) => STATE_RANK[a.state] - STATE_RANK[b.state]
+ || (GROUP_IDS.indexOf(a.group) + 1 || 99) - (GROUP_IDS.indexOf(b.group) + 1 || 99)
+ || a.service_name.localeCompare(b.service_name) || a.title.localeCompare(b.title);
+
+/**
+ * The checklist: rows sorted Needed, Not working, Connected, Optional; groups ordered by their most urgent row
+ * (so the page opens on what needs the owner), then by the design's order. The counts add up to the rows.
+ */
+function checklist(rows) {
+ const sorted = [...rows].sort(byState);
+ const counts = { needed: 0, not_working: 0, connected: 0, optional: 0, total: rows.length };
+ for (const r of rows) counts[r.state] += 1;
+ const groups = new Map();
+ for (const r of sorted) {
+ if (!groups.has(r.group)) groups.set(r.group, { id: r.group, label: groupLabel(r.group), rows: [], counts: { needed: 0, not_working: 0, connected: 0, optional: 0 } });
+ const g = groups.get(r.group);
+ g.rows.push(r);
+ g.counts[r.state] += 1;
+ }
+ const rank = (g) => Math.min(...g.rows.map((r) => STATE_RANK[r.state]));
+ const order = (id) => (GROUP_IDS.indexOf(id) + 1) || 99;
+ const list = [...groups.values()].sort((a, b) => rank(a) - rank(b) || order(a.id) - order(b.id));
+ const empty = GROUP_IDS.filter((id) => !groups.has(id)).map((id) => ({ id, label: GROUPS[id] }));
+ return { rows: sorted, groups: list, empty_groups: empty, counts };
+}
+
+function createSetupRoutes(opts = {}) {
+ const r = express.Router();
+ const log = opts.log || console;
+ const now = opts.now || (() => Date.now());
+ const caller = createServiceCaller(opts);
+ r.use(requireOwner);
+
+ const send = (res, status, body) => { res.set('Cache-Control', 'no-store'); return res.status(status).json(body); };
+ const dbOf = (req) => opts.db || req.app.locals.db;
+
+ r.get('/', async (req, res) => {
+ try {
+ const db = dbOf(req);
+ const services = opts.services || SERVICES;
+ const [answers, tests, store] = await Promise.all([
+ Promise.all(services.map(async (service) => {
+ try { return { service, ...(await caller.serviceNamespaces(req, service, { timeout: LIST_TIMEOUT_MS })) }; } catch (err) {
+ log.warn(`[Setup] ${service}: ${err && err.message ? String(err.message).split('\n')[0].slice(0, 160) : 'failed'}`);
+ return { service, answered: false, detail: `${label(service)} did not answer` };
+ }
+ })),
+ latestTests(db),
+ tasksStore(db, log),
+ ]);
+ const rows = [];
+ const reach = [];
+ for (const a of answers) {
+ let count = 0;
+ for (const ns of a.answered ? a.namespaces : []) {
+ const props = ns.schema && ns.schema.properties && typeof ns.schema.properties === 'object' ? ns.schema.properties : {};
+ for (const [key, prop] of Object.entries(props)) {
+ if (!prop || typeof prop !== 'object' || !prop['x-owner-input'] || typeof prop['x-owner-input'] !== 'object') continue;
+ rows.push(keyRow(a.service, ns, key, prop, tests));
+ count += 1;
+ }
+ }
+ reach.push({
+ service: a.service, name: label(a.service), answered: !!a.answered,
+ namespaces: a.answered ? a.namespaces.length : 0, rows: count,
+ // In plain words, as the AI tab says it: a service that has no settings to show yet.
+ note: !a.answered || !a.namespaces.length ? `${label(a.service)} has not published settings yet.` : (count ? null : `${label(a.service)} asks nothing of you yet.`),
+ });
+ }
+ const values = store.get();
+ for (const [key, prop] of Object.entries(TASKS_SCHEMA.properties)) rows.push(taskRow(key, prop, values[key]));
+ send(res, 200, { ok: true, generated_at: new Date(now()).toISOString(), ...checklist(rows), services: reach });
+ } catch (err) {
+ log.error(`[Setup] ${err && err.message ? String(err.message).split('\n')[0].slice(0, 200) : 'failed'}`);
+ send(res, 500, { ok: false, error: 'Setup could not be read', code: 'setup.internal' });
+ }
+ });
+
+ r.post('/tasks/:key', async (req, res) => {
+ try {
+ const key = String(req.params.key || '');
+ if (!KEY_RE.test(key) || !Object.prototype.hasOwnProperty.call(TASKS_SCHEMA.properties, key)) return send(res, 404, { ok: false, error: 'No such to-do', code: 'setup.task_not_found' });
+ const body = req.body && typeof req.body === 'object' ? req.body : {};
+ if (typeof body.done !== 'boolean') return send(res, 400, { ok: false, error: 'Say done: true or false', code: 'setup.bad_request' });
+ const db = dbOf(req);
+ const store = await tasksStore(db, log);
+ const actor = siteConfig.actorOf(req.user);
+ const title = TASKS_SCHEMA.properties[key].title;
+ if (!body.done && store.get(key) == null) return send(res, 200, { ok: true, row: taskRow(key, TASKS_SCHEMA.properties[key], null) });
+ const snap = body.done
+ ? await store.apply({ [key]: { done_at: new Date(now()).toISOString(), done_by: String(req.user.username || '').slice(0, 80) } }, { merge: true, actor, reason: `Marked done: ${title}` })
+ : await store.apply({}, { merge: true, unset: [key], actor, reason: `Marked not done: ${title}` });
+ try {
+ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)')
+ .run(req.user.id, 'setup_task', JSON.stringify({ key, done: body.done, revision: snap && snap.revision }));
+ } catch (err) { log.warn(`[Setup] audit row failed: ${err.message}`); }
+ send(res, 200, { ok: true, row: taskRow(key, TASKS_SCHEMA.properties[key], store.get(key)) });
+ } catch (err) {
+ log.error(`[Setup] ${err && err.message ? String(err.message).split('\n')[0].slice(0, 200) : 'failed'}`);
+ send(res, 500, { ok: false, error: 'The to-do could not be saved', code: 'setup.internal' });
+ }
+ });
+
+ return r;
+}
+
+module.exports = { createSetupRoutes, checklist, keyRow, taskRow, isSecretKey, tasksStore, latestTests, TASKS_SCHEMA, NAMESPACE, GROUPS, STATES };
@@ -183,5 +183,25 @@ module.exports = {
@@ -183,5 +183,25 @@ module.exports = {
actor: {
actor: {
internalUrl: process.env.OV_ACTOR_INTERNAL_URL || 'http://127.0.0.1:4950',
internalUrl: process.env.OV_ACTOR_INTERNAL_URL || 'http://127.0.0.1:4950',
},
},
+ // The owner's Setup tab (server/admin/setup.js) reaches every service that holds an owner input. Chat and
+ // Media are above; these are the rest.
+ connect: {
+ internalUrl: process.env.OV_CONNECT_INTERNAL_URL || 'http://127.0.0.1:5240',
+ },
+ deals: {
+ internalUrl: process.env.OV_DEALS_INTERNAL_URL || 'http://127.0.0.1:4840',
+ },
+ work: {
+ internalUrl: process.env.OV_WORK_INTERNAL_URL || 'http://127.0.0.1:4960',
+ },
+ sources: {
+ internalUrl: process.env.OV_SOURCES_INTERNAL_URL || 'http://127.0.0.1:4720',
+ },
+ design: {
+ internalUrl: process.env.OV_DESIGN_INTERNAL_URL || 'http://127.0.0.1:5090',
+ },
+ domains: {
+ internalUrl: process.env.OV_DOMAINS_INTERNAL_URL || 'http://127.0.0.1:5070',
+ },
},
},
};
};
@@ -336,6 +336,14 @@ const DEFAULT_GRANTS = [
@@ -336,6 +336,14 @@ const DEFAULT_GRANTS = [
// CHANGED_DEFAULT_NAMESPACES entry is needed. The deploy applies them from ensureSchema() at boot.
// CHANGED_DEFAULT_NAMESPACES entry is needed. The deploy applies them from ensureSchema() at boot.
...['ai', 'billing', 'services', 'watch', 'bench', 'report', 'host', 'codes', 'actor']
...['ai', 'billing', 'services', 'watch', 'bench', 'report', 'host', 'codes', 'actor']
.map((svc) => ['network', `${svc}.config.manage`, `openvibe.${svc}`, []]),
.map((svc) => ['network', `${svc}.config.manage`, `openvibe.${svc}`, []]),
+ // The owner's Setup tab (OWNER-SETUP-AND-ADMIN-CLARITY-DESIGN, 2026-11-07): the same proxy reaches the eight more
+ // services that hold an owner input. Their <svc>.config.manage capabilities are Contracts 0.308.0 (planned until
+ // each service mounts /internal/config). A grant is seeded only once the pinned Contracts knows its capability, so
+ // this works on the pin before 0.308.0 and seeds all eight at the first boot after the pin moves. New rows, as for
+ // the nine: no RESTORED_GRANTS or CHANGED_DEFAULT_NAMESPACES entry.
+ ...['chat', 'connect', 'deals', 'work', 'sources', 'design', 'domains', 'media']
+ .filter((svc) => capabilities.get(`${svc}.config.manage`))
+ .map((svc) => ['network', `${svc}.config.manage`, `openvibe.${svc}`, []]),
// The owner's AI tab lists and edits AI's provider registry through AI's own admin API (GET/POST/PATCH
// The owner's AI tab lists and edits AI's provider registry through AI's own admin API (GET/POST/PATCH
// /api/v1/providers), as Network, naming the owner in X-OV-Subject.
// /api/v1/providers), as Network, naming the owner in X-OV-Subject.
['network', 'ai.provider.manage', 'openvibe.ai', []],
['network', 'ai.provider.manage', 'openvibe.ai', []],
new file mode 100644
new file mode 100644
@@ -0,0 +1,86 @@
@@ -0,0 +1,86 @@
+'use strict';
+// The AI tab in plain words (public/js/admin/ai.js; OWNER-SETUP-AND-ADMIN-CLARITY-DESIGN, 2026-11-07, sections 2
+// and 3): the table's words for billing, access, keys and health; a title with the machine key small and grey after
+// it (a key without a title reads as words); every choice from x-enum-labels; x-advanced fields and raw JSON in a
+// counted "Advanced settings (N)" fold; an object with described properties as a small form; an unset secret offers
+// the write-only route.
+// node test/admin-ai-clarity.test.js
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const vm = require('vm');
+
+const src = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'admin', 'ai.js'), 'utf8');
+const ctx = {
+ esc: (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''),
+ timeAgo: () => '2h ago', currentUser: null, console,
+};
+vm.createContext(ctx);
+vm.runInContext(src + '\n;globalThis.__pai = { paiSetupOnly, paiField, paiCard, paiKeyState, paiHealth, paiHumanize, PAI_BILLING_LABEL, PAI_ACCESS_LABEL, paiProviderRow };', ctx);
+const P = ctx.__pai;
+
+// ── The table's words, verbatim ──
+// A namespace made only of non-AI owner inputs (Codes' publishing tokens) is the Setup tab's, not "AI settings".
+{
+ const oi = (group) => ({ type: 'string', 'x-owner-input': { group, unlocks: 'x' } });
+ assert.strictEqual(P.paiSetupOnly({ schema: { properties: { npm_token: oi('design-publishing'), chrome: oi('design-publishing') } } }), true);
+ assert.strictEqual(P.paiSetupOnly({ schema: { properties: { key: oi('ai-providers'), npm_token: oi('design-publishing') } } }), false, 'one AI input keeps it on the AI tab');
+ assert.strictEqual(P.paiSetupOnly({ schema: { properties: { model: { type: 'string' } } } }), false, 'a plain AI setting stays');
+ assert.strictEqual(P.paiSetupOnly({ schema: { properties: {} } }), false);
+}
+assert.deepStrictEqual({ ...P.PAI_BILLING_LABEL }, {
+ metered: 'Spends Credits per request', subscription: 'A plan with a monthly fee shared by everyone',
+ payg: 'A prepaid balance at the provider', free: 'Free', byok: 'The person\'s own key pays',
+});
+assert.deepStrictEqual({ ...P.PAI_ACCESS_LABEL }, {
+ OWN_DEPLOYMENT: 'Runs on our own machine: no provider terms', AUTHORIZED_WHOLESALE: 'Our agreement allows reselling it',
+ CUSTOMER_BYOK: 'Only with the person\'s own key', RESTRICTED: 'Do not sell: their terms forbid it',
+ UNVERIFIED: 'Not checked yet: treated as Do not sell',
+});
+assert.match(P.paiKeyState({ secret_ref: 'vault:deepseek', credentials: 'configured', auth_mode: 'bearer', base_url: 'https://api.deepseek.com' }).text, /^Key: Entered here, locked/);
+assert.strictEqual(P.paiKeyState({ secret_ref: 'env:DEEPSEEK_API_KEY', credentials: 'configured', auth_mode: 'bearer' }).text, 'Key: From the server\'s settings file (DEEPSEEK_API_KEY)');
+assert.strictEqual(P.paiKeyState({ secret_ref: null, credentials: 'missing', auth_mode: 'bearer' }).text, 'Key: Not set');
+assert.strictEqual(P.paiHealth({ health: { state: 'closed' } }).text, 'Health: Working');
+assert.strictEqual(P.paiHealth({ health: { state: 'half_open' } }).text, 'Health: Testing again');
+assert.match(P.paiHealth({ health: { state: 'open', consecutive_failures: 5, opened_at: Date.UTC(2026, 10, 7, 14, 4) } }).text, /^Health: Paused after 5 errors; tries again at \d{1,2}[:.]\d{2}/);
+const row = P.paiProviderRow({ key: 'openrouter', display_name: 'OpenRouter', base_url: 'https://openrouter.ai', secret_ref: 'env:X', credentials: 'configured', auth_mode: 'bearer', billing_profile: 'metered', provider_access: 'UNVERIFIED', status: 'active', health: { state: 'closed' } });
+assert.ok(!/circuit|half.open|Sealed|vault|BYOK|Metered/i.test(row.replace(/data-act="[^"]*"/g, '')), 'no internal word is shown on a provider row');
+
+// ── Titles, keys, choices ──
+const n = {
+ service: 'ai', namespace: 'ai.exchange', revision: 3, pins: [],
+ values: { min_samples: 5, routing: 'auto', limits: { per_minute: 20 }, raw: { a: 1 }, alpha: 0.2 },
+ classification: { min_samples: 'internal', routing: 'internal', limits: 'internal', raw: 'internal', alpha: 'internal' },
+ schema: {
+ title: 'What the Exchange offers', type: 'object',
+ properties: {
+ min_samples: { type: 'integer', description: 'Below this many attempts, evidence is reported and never trusted.' },
+ routing: { type: 'string', title: 'How a model is chosen', enum: ['auto', 'pinned'], 'x-enum-labels': { auto: { label: 'By what works best for the money', description: 'Measured cost per success decides' } } },
+ limits: { type: 'object', title: 'Per person limits', properties: { per_minute: { type: 'integer', title: 'Requests a minute' } } },
+ raw: { type: 'object', title: 'Route overrides' },
+ alpha: { type: 'number', title: 'How fast new measurements count', 'x-advanced': true },
+ provider_key: { type: 'string', title: 'Provider key' },
+ },
+ },
+};
+const f = P.paiField(n, 'min_samples', n.schema.properties.min_samples, false, null);
+assert.match(f, /<label>Min samples <code>min_samples<\/code><\/label>/, 'a key without a title reads as words, the key small after it');
+const choice = P.paiField(n, 'routing', n.schema.properties.routing, false, null);
+assert.match(choice, /<option value="auto" selected data-desc="Measured cost per success decides">By what works best for the money<\/option>/, 'an option in words with its line');
+assert.match(choice, /<option value="pinned">Pinned<\/option>/, 'an option without a label still reads as a word');
+const form = P.paiField(n, 'limits', n.schema.properties.limits, false, null);
+assert.match(form, /data-pai-object/, 'an object whose properties are described is a small form');
+assert.ok(!/<textarea/.test(form), 'not raw JSON');
+const secret = P.paiField(n, 'provider_key', n.schema.properties.provider_key, false, null);
+assert.match(secret, /Not set/);
+assert.match(secret, /data-act="secret"[^>]*>Enter it</, 'an unset key goes to the write-only route, not a plain field');
+const card = P.paiCard(n);
+assert.match(card, /<h5>What the Exchange offers <code class="pai-mkey">ai\.exchange<\/code><\/h5>/, 'the card title, with the namespace small after it');
+assert.match(card, /<summary>Advanced settings \(2\)<\/summary>/, 'x-advanced and raw JSON fold under a counted Advanced settings');
+const [mainPart, advancedPart] = card.split('<details class="pai-advanced">');
+assert.ok(!/<textarea/.test(mainPart), 'raw JSON is only under Advanced settings');
+assert.match(advancedPart, /data-key="raw"/);
+assert.match(advancedPart, /data-key="alpha"/);
+assert.match(card, /Saved version 3/);
+assert.strictEqual(P.paiHumanize('vast.key'), 'Vast key');
+console.log('admin-ai-clarity: the table\'s words, titles, choices, folds and forms render as designed');
@@ -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', 'payments.js', 'ai.js', 'content.js', 'analytics.js', 'deploy.js', 'boot.js'];
+const modules = ['core.js', 'dashboard.js', 'users.js', 'moderation.js', 'settings.js', 'payments.js', 'glossary.js', 'ai.js', 'setup.js', 'content.js', 'analytics.js', 'deploy.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,60 @@
@@ -0,0 +1,60 @@
+'use strict';
+// The Setup tab's words and the glossary in the browser scripts (public/js/admin/setup.js, public/js/admin/glossary.js;
+// OWNER-SETUP-AND-ADMIN-CLARITY-DESIGN, 2026-11-07): the counted summary, each state as a sentence, the glossary's
+// words with their "What does this mean?" links, and the owner-only tab in public/js/admin/core.js.
+// node test/admin-setup-ui.test.js
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const vm = require('vm');
+
+const dir = path.join(__dirname, '..', 'public', 'js', 'admin');
+const read = (f) => fs.readFileSync(path.join(dir, f), 'utf8');
+const ctx = {
+ esc: (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''),
+ timeAgo: () => '5m ago', currentUser: null, console,
+ document: { addEventListener() {} }, location: { hash: '' },
+};
+vm.createContext(ctx);
+// glossary.js, ai.js (paiUsd) and setup.js in their load order, as admin.html loads them.
+vm.runInContext(read('glossary.js') + '\n' + read('ai.js') + '\n' + read('setup.js')
+ + '\n;globalThis.__s = { setupSummary, setupStateLine, setupFor, glossaryLink, GLOSSARY };', ctx);
+const S = ctx.__s;
+
+// ── The counted summary, in the design's words ──
+assert.strictEqual(S.setupSummary({ needed: 6, not_working: 2, connected: 31, optional: 0 }), '6 things need you · 2 not working · 31 connected');
+assert.strictEqual(S.setupSummary({ needed: 1, not_working: 0, connected: 4, optional: 3 }), '1 thing needs you · 0 not working · 4 connected · 3 optional');
+
+// ── Each state is a sentence ──
+const key = { kind: 'key', secret: true, testable: true };
+assert.strictEqual(S.setupStateLine({ ...key, state: 'needed', required_for: 'Card payments' }), 'Needed for card payments.');
+assert.strictEqual(S.setupStateLine({ ...key, state: 'needed', required_for: 'GPUs for rent' }), 'Needed for GPUs for rent.', 'an acronym keeps its capitals');
+assert.strictEqual(S.setupStateLine({ ...key, state: 'optional' }), 'Not set. Nothing waits on it.');
+assert.strictEqual(S.setupStateLine({ ...key, state: 'not_working', set: true, hint: 'WXYZ', set_at: '2026-11-07', last_test: { passed: false, message: 'Telegram answered 401', at: 'x' } }),
+ 'Entered here, locked · ••••WXYZ · set 2026-11-07; the last test failed 5m ago: Telegram answered 401.');
+assert.strictEqual(S.setupStateLine({ ...key, state: 'connected', set: true, hint: 'a1b2', last_test: null }), 'Entered here, locked · ••••a1b2; not tested yet.');
+assert.strictEqual(S.setupStateLine({ kind: 'key', state: 'connected', set: true, pinned: true, pin_env: 'NPM_TOKEN', secret: true, last_test: null }), 'From the server\'s settings file (NPM_TOKEN).');
+assert.strictEqual(S.setupStateLine({ kind: 'key', state: 'connected', set: true, value: false, type: 'boolean' }), 'Off.', 'a switch says on or off, not true or false');
+assert.strictEqual(S.setupStateLine({ kind: 'key', state: 'connected', set: true, value: 2500000000, unit: 'nano_usd' }), 'Set to $2.5.');
+assert.strictEqual(S.setupStateLine({ kind: 'task', state: 'connected', done: { at: '2026-11-07', by: 'goosely' } }), 'Done on 2026-11-07 by @goosely.');
+assert.strictEqual(S.setupStateLine({ kind: 'task', state: 'needed', required_for: 'Safe harbour for what people upload' }), 'Needed for safe harbour for what people upload.');
+assert.ok(!/undefined|null|true|false/.test(S.setupStateLine({ ...key, state: 'connected', set: true, last_test: { passed: true, at: 'x', message: 'ok' } })));
+
+// ── The glossary: the words the design names, each with a line, and the link next to a word ──
+const ids = S.GLOSSARY.map((g) => g.id);
+for (const id of ['credits', 'byok', 'resale', 'cache', 'quota', 'workflow']) assert.ok(ids.includes(id), `the glossary explains ${id}`);
+// 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('nope'), '', 'no link for a word the glossary does not have');
+
+// ── Setup is an owner-only tab (public/js/admin/core.js) ──
+const core = read('core.js');
+assert.match(core.match(/const OWNER_ONLY_TABS = new Set\(\[[^\]]*\]\)/)[0], /'setup'/);
+assert.ok(!/OWNER_ONLY_TABS = new Set\(\[[^\]]*'glossary'/.test(core), 'the glossary is for every admin');
+// A key's input is built as a DOM node, never in an HTML string, and is cleared after a save.
+const setup = read('setup.js');
+assert.ok(!/<input[^>]*type="password"/.test(setup), 'no password input in an HTML string');
+assert.match(setup, /if \(isSecret\) input\.value = '';/, 'the key is cleared whatever happened');
+console.log('admin-setup-ui: the summary, the states, the glossary and the owner-only tab read as designed');
@@ -4,6 +4,8 @@
@@ -4,6 +4,8 @@
// and (when fresh) X-OV-Fresh; a secret write or an x-danger change needs a sign-in within 10 minutes; an invalid
// and (when fresh) X-OV-Fresh; a secret write or an x-danger change needs a sign-in within 10 minutes; an invalid
// summary is dropped with a warning; every secret and danger change is audited and raises an operator alert; and no
// summary is dropped with a warning; every secret and danger change is audited and raises an operator alert; and no
// value ever reaches a Network table or log line.
// value ever reaches a Network table or log line.
+// The Setup tab (2026-11-07) adds: seventeen services, each secret's marker (hint and date) forwarded, and the test
+// route, whose outcome is kept as an ai_config_test audit row and never carries a value.
// node test/ai-config-proxy.test.js
// node test/ai-config-proxy.test.js
const assert = require('assert');
const assert = require('assert');
const http = require('http');
const http = require('http');
@@ -81,7 +83,7 @@ function summary(service, namespace, extra = {}) {
@@ -81,7 +83,7 @@ function summary(service, namespace, extra = {}) {
const ownerCall = (method, p, body) => call({ authorization: `Bearer ${fresh}`, cookie: `ov_token=${fresh}` }, method, p, body);
const ownerCall = (method, p, body) => call({ authorization: `Bearer ${fresh}`, cookie: `ov_token=${fresh}` }, method, p, body);
// ── The answer the fakes give, per namespace ───────────────────
// ── The answer the fakes give, per namespace ───────────────────
- const exchange = summary('ai', 'ai.exchange', { kek: 'k1' });
+ const exchange = summary('ai', 'ai.exchange', { kek: 'k1', secrets: { provider_key: { redacted: true, fingerprint: FP, hint: 'a1b2', set_at: NOW, set_by: { type: 'user', id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ' }, bound_to: 'https://api.deepseek.com', value: SECRET } } });
const broken = summary('ai', 'ai.broken', { values: { api_key: { redacted: true, fingerprint: FP, hint: 'a1b2', value: SECRET } } });
const broken = summary('ai', 'ai.broken', { values: { api_key: { redacted: true, fingerprint: FP, hint: 'a1b2', value: SECRET } } });
const credits = summary('billing', 'billing.credits', { sealed: false });
const credits = summary('billing', 'billing.credits', { sealed: false });
const creditsBroken = summary('billing', 'billing.broken', { values: { api_key: { redacted: true, fingerprint: FP, value: 'a-plain-value' } } });
const creditsBroken = summary('billing', 'billing.broken', { values: { api_key: { redacted: true, fingerprint: FP, value: 'a-plain-value' } } });
@@ -93,6 +95,7 @@ function summary(service, namespace, extra = {}) {
@@ -93,6 +95,7 @@ function summary(service, namespace, extra = {}) {
'POST /internal/config/ai.exchange/rollback': { service: 'ai', namespace: 'ai.exchange', revision: 9, state: 'active' },
'POST /internal/config/ai.exchange/rollback': { service: 'ai', namespace: 'ai.exchange', revision: 9, state: 'active' },
'PUT /internal/config/ai.exchange/secrets/provider_key': { redacted: true, fingerprint: FP, hint: 'a1b2', set_at: NOW, bound_to: 'https://api.deepseek.com' },
'PUT /internal/config/ai.exchange/secrets/provider_key': { redacted: true, fingerprint: FP, hint: 'a1b2', set_at: NOW, bound_to: 'https://api.deepseek.com' },
'DELETE /internal/config/ai.exchange/secrets/provider_key': { redacted: true, fingerprint: FP, hint: 'a1b2', set_at: NOW, bound_to: null },
'DELETE /internal/config/ai.exchange/secrets/provider_key': { redacted: true, fingerprint: FP, hint: 'a1b2', set_at: NOW, bound_to: null },
+ 'POST /internal/config/ai.exchange/test/provider_key': { ok: false, message: '401 Unauthorized:\n the key was refused' },
'PUT /internal/config/ai.provider_keys/secrets/deepseek': { redacted: true, fingerprint: FP, hint: 'a1b2', set_at: NOW, bound_to: 'https://api.deepseek.com' },
'PUT /internal/config/ai.provider_keys/secrets/deepseek': { redacted: true, fingerprint: FP, hint: 'a1b2', set_at: NOW, bound_to: 'https://api.deepseek.com' },
'DELETE /internal/config/ai.provider_keys/secrets/deepseek': { redacted: true, fingerprint: FP, hint: 'a1b2', set_at: NOW, bound_to: null },
'DELETE /internal/config/ai.provider_keys/secrets/deepseek': { redacted: true, fingerprint: FP, hint: 'a1b2', set_at: NOW, bound_to: null },
'GET /api/v1/providers': { providers: [{ key: 'deepseek', display_name: 'DeepSeek', kind: 'openai', status: 'active', base_url: 'https://api.deepseek.com', secret_ref: 'vault:deepseek', billing_profile: 'metered', metadata: { provider_access: 'OWN_DEPLOYMENT' }, credentials: 'configured' }] },
'GET /api/v1/providers': { providers: [{ key: 'deepseek', display_name: 'DeepSeek', kind: 'openai', status: 'active', base_url: 'https://api.deepseek.com', secret_ref: 'vault:deepseek', billing_profile: 'metered', metadata: { provider_access: 'OWN_DEPLOYMENT' }, credentials: 'configured' }] },
@@ -111,10 +114,12 @@ function summary(service, namespace, extra = {}) {
@@ -111,10 +114,12 @@ function summary(service, namespace, extra = {}) {
assert.strictEqual(r.status, 403, 'a plain admin never gets the owner settings');
assert.strictEqual(r.status, 403, 'a plain admin never gets the owner settings');
assert.strictEqual(configCalls().length, 0, 'a refused request never reaches a service');
assert.strictEqual(configCalls().length, 0, 'a refused request never reaches a service');
- // ── GET /services: the nine services and their operator-set internal URLs ──
+ // ── GET /services: the seventeen services and their operator-set internal URLs ──
r = await ownerCall('GET', '/api/admin/ai/config/services');
r = await ownerCall('GET', '/api/admin/ai/config/services');
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
- assert.strictEqual(r.body.services.length, 9);
+ assert.deepStrictEqual(r.body.services.map((s) => s.service), ['ai', 'billing', 'services', 'watch', 'bench', 'report', 'host', 'codes', 'actor', 'chat', 'connect', 'deals', 'work', 'sources', 'design', 'domains', 'media']);
+ assert.strictEqual(r.body.services.find((s) => s.service === 'deals').name, 'OpenVibe.Deals');
+ assert.strictEqual(r.body.services.find((s) => s.service === 'deals').capability, 'deals.config.manage');
assert.strictEqual(r.body.services.find((s) => s.service === 'ai').internal_url, ai.url);
assert.strictEqual(r.body.services.find((s) => s.service === 'ai').internal_url, ai.url);
assert.strictEqual(r.body.services.find((s) => s.service === 'billing').internal_url, billing.url);
assert.strictEqual(r.body.services.find((s) => s.service === 'billing').internal_url, billing.url);
@@ -130,6 +135,8 @@ function summary(service, namespace, extra = {}) {
@@ -130,6 +135,8 @@ function summary(service, namespace, extra = {}) {
assert.match(r.body.warnings[0].detail, /api_key|redacted|additional/i);
assert.match(r.body.warnings[0].detail, /api_key|redacted|additional/i);
assert.deepStrictEqual(r.body.vault, { sealed: true, kek: 'k1' });
assert.deepStrictEqual(r.body.vault, { sealed: true, kek: 'k1' });
assert.ok(!r.text.includes(SECRET), 'the dropped summary never reaches the browser');
assert.ok(!r.text.includes(SECRET), 'the dropped summary never reaches the browser');
+ assert.deepStrictEqual(r.body.namespaces[0].secret_markers, { provider_key: { hint: 'a1b2', set_at: NOW, fingerprint: FP, bound_to: 'https://api.deepseek.com' } },
+ 'a secret\'s hint and date reach the panel, and nothing else of its marker');
const listCall = ai.seen.find((x) => x.method === 'GET' && x.url === '/internal/config');
const listCall = ai.seen.find((x) => x.method === 'GET' && x.url === '/internal/config');
assert.match(listCall.headers.authorization, /^Bearer /, 'the service gets Network\'s own token');
assert.match(listCall.headers.authorization, /^Bearer /, 'the service gets Network\'s own token');
assert.strictEqual(listCall.headers['x-ov-subject'], owner.subject, 'the owner travels in X-OV-Subject');
assert.strictEqual(listCall.headers['x-ov-subject'], owner.subject, 'the owner travels in X-OV-Subject');
@@ -185,6 +192,24 @@ function summary(service, namespace, extra = {}) {
@@ -185,6 +192,24 @@ function summary(service, namespace, extra = {}) {
assert.deepStrictEqual({ service: secretAudit.service, namespace: secretAudit.namespace, key: secretAudit.key, action: secretAudit.action }, { service: 'ai', namespace: 'ai.exchange', key: 'provider_key', action: 'secret.put' });
assert.deepStrictEqual({ service: secretAudit.service, namespace: secretAudit.namespace, key: secretAudit.key, action: secretAudit.action }, { service: 'ai', namespace: 'ai.exchange', key: 'provider_key', action: 'secret.put' });
assert.ok(!JSON.stringify(secretAudit).includes(SECRET));
assert.ok(!JSON.stringify(secretAudit).includes(SECRET));
+ // ── Test: the service's one-line answer, kept as an audit row with the fingerprint it tried ──
+ r = await call('staff', 'POST', '/api/admin/ai/config/ai/ai.exchange/test/provider_key', {});
+ assert.strictEqual(r.status, 403, 'a plain admin cannot run a test');
+ r = await call({ authorization: `Bearer ${stale}`, cookie: `ov_token=${stale}` }, 'POST', '/api/admin/ai/config/ai/ai.exchange/test/provider_key', {});
+ assert.strictEqual(r.status, 200, 'a test needs no fresh sign-in: it changes nothing and shows nothing of the value');
+ assert.deepStrictEqual({ ok: r.body.ok, passed: r.body.passed, message: r.body.message }, { ok: true, passed: false, message: '401 Unauthorized: the key was refused' }, 'one line');
+ const testCall = ai.seen.filter((x) => x.method === 'POST' && x.url === '/internal/config/ai.exchange/test/provider_key').at(-1);
+ assert.strictEqual(testCall.headers['x-ov-subject'], owner.subject);
+ assert.match(testCall.headers.authorization, /^Bearer /);
+ const testRow = JSON.parse((await w.db.prepare("SELECT details FROM audit_log WHERE action = 'ai_config_test' ORDER BY id DESC LIMIT 1").get()).details);
+ assert.deepStrictEqual({ service: testRow.service, namespace: testRow.namespace, key: testRow.key, passed: testRow.passed, message: testRow.message },
+ { service: 'ai', namespace: 'ai.exchange', key: 'provider_key', passed: false, message: '401 Unauthorized: the key was refused' }, 'the test row names the key and keeps the answer');
+ const { triedDigest } = require('../server/admin/ai-config-proxy');
+ assert.strictEqual(testRow.tried, triedDigest('ai', 'ai.exchange', 'provider_key', undefined, { fingerprint: FP }), 'it keeps a digest of what it tried');
+ assert.ok(!JSON.stringify(testRow).includes(FP), 'never the marker\'s fingerprint itself');
+ r = await ownerCall('POST', '/api/admin/ai/config/ai/ai.exchange/test/nothing_here', {});
+ assert.strictEqual(r.status, 404, 'a key the service does not test relays the service\'s answer');
+
// ── Secret delete, rollback, and the provider registry (GET/POST/PATCH and its write-only key) ──
// ── Secret delete, rollback, and the provider registry (GET/POST/PATCH and its write-only key) ──
r = await ownerCall('DELETE', '/api/admin/ai/config/ai/ai.exchange/secrets/provider_key', { reason: 'gone' });
r = await ownerCall('DELETE', '/api/admin/ai/config/ai/ai.exchange/secrets/provider_key', { reason: 'gone' });
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
@@ -219,6 +244,17 @@ function summary(service, namespace, extra = {}) {
@@ -219,6 +244,17 @@ function summary(service, namespace, extra = {}) {
for (const svc of ['ai', 'billing', 'services', 'watch', 'bench', 'report', 'host', 'codes', 'actor']) {
for (const svc of ['ai', 'billing', 'services', 'watch', 'bench', 'report', 'host', 'codes', 'actor']) {
assert.ok(has('network', `${svc}.config.manage`, `openvibe.${svc}`), `Network holds ${svc}.config.manage`);
assert.ok(has('network', `${svc}.config.manage`, `openvibe.${svc}`), `Network holds ${svc}.config.manage`);
}
}
+ // The eight Setup services: Network holds each grant as soon as the pinned Contracts (0.308.0) knows it.
+ const { capabilities, version: contractsVersion } = require('openvibe-contracts');
+ const setupServices = ['chat', 'connect', 'deals', 'work', 'sources', 'design', 'domains', 'media'];
+ for (const svc of setupServices) {
+ const known = !!capabilities.get(`${svc}.config.manage`);
+ assert.strictEqual(has('network', `${svc}.config.manage`, `openvibe.${svc}`), known, `Network holds ${svc}.config.manage exactly when Contracts knows it`);
+ }
+ const [maj, min] = String(contractsVersion || require('openvibe-contracts/package.json').version).split('.').map(Number);
+ if (maj > 0 || min >= 308) {
+ for (const svc of setupServices) assert.ok(has('network', `${svc}.config.manage`, `openvibe.${svc}`), `from Contracts 0.308.0 Network holds ${svc}.config.manage`);
+ }
assert.ok(has('network', 'ai.provider.manage', 'openvibe.ai'), 'Network holds ai.provider.manage');
assert.ok(has('network', 'ai.provider.manage', 'openvibe.ai'), 'Network holds ai.provider.manage');
assert.ok(has('host', 'ai.usage.read', 'openvibe.ai'), 'Host holds ai.usage.read');
assert.ok(has('host', 'ai.usage.read', 'openvibe.ai'), 'Host holds ai.usage.read');
// The boot seed only inserts a grant when its client exists. In production the network principal does; here we
// The boot seed only inserts a grant when its client exists. In production the network principal does; here we
@@ -226,7 +262,9 @@ function summary(service, namespace, extra = {}) {
@@ -226,7 +262,9 @@ function summary(service, namespace, extra = {}) {
await w.db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES ('network', 'x', 'OpenVibe.Network (service)', '[]', 1) ON CONFLICT DO NOTHING").run();
await w.db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES ('network', 'x', 'OpenVibe.Network (service)', '[]', 1) ON CONFLICT DO NOTHING").run();
await principals.ensureSchema(w.db);
await principals.ensureSchema(w.db);
const seeded = (await w.db.prepare("SELECT capability, audience FROM principal_grants WHERE client_id = 'network' AND capability LIKE '%config.manage' AND revoked_at IS NULL ORDER BY capability").all()).map((x) => `${x.capability}|${x.audience}`).sort();
const seeded = (await w.db.prepare("SELECT capability, audience FROM principal_grants WHERE client_id = 'network' AND capability LIKE '%config.manage' AND revoked_at IS NULL ORDER BY capability").all()).map((x) => `${x.capability}|${x.audience}`).sort();
- assert.deepStrictEqual(seeded, ['actor.config.manage|openvibe.actor', 'ai.config.manage|openvibe.ai', 'bench.config.manage|openvibe.bench', 'billing.config.manage|openvibe.billing', 'codes.config.manage|openvibe.codes', 'host.config.manage|openvibe.host', 'report.config.manage|openvibe.report', 'services.config.manage|openvibe.services', 'watch.config.manage|openvibe.watch']);
+ const expectSeeded = ['actor', 'ai', 'bench', 'billing', 'codes', 'host', 'report', 'services', 'watch', ...setupServices.filter((svc) => capabilities.get(`${svc}.config.manage`))]
+ .map((svc) => `${svc}.config.manage|openvibe.${svc}`).sort();
+ assert.deepStrictEqual(seeded, expectSeeded);
assert.ok(await w.db.prepare("SELECT 1 FROM principal_grants WHERE client_id = 'network' AND capability = 'ai.provider.manage' AND audience = 'openvibe.ai' AND revoked_at IS NULL").get(), 'the provider grant is seeded');
assert.ok(await w.db.prepare("SELECT 1 FROM principal_grants WHERE client_id = 'network' AND capability = 'ai.provider.manage' AND audience = 'openvibe.ai' AND revoked_at IS NULL").get(), 'the provider grant is seeded');
assert.ok(await w.db.prepare("SELECT 1 FROM principal_grants WHERE client_id = 'host' AND capability = 'ai.usage.read' AND audience = 'openvibe.ai' AND revoked_at IS NULL").get(), 'Host\'s usage grant is seeded');
assert.ok(await w.db.prepare("SELECT 1 FROM principal_grants WHERE client_id = 'host' AND capability = 'ai.usage.read' AND audience = 'openvibe.ai' AND revoked_at IS NULL").get(), 'Host\'s usage grant is seeded');
@@ -127,7 +127,7 @@ const REVIEWED = {
@@ -127,7 +127,7 @@ const REVIEWED = {
'server/stats/open.js fetch': [1, 'open stats (/stats): fixed first-party loopback URLs only — Live (OV_LIVE_INTERNAL_URL, its public home numbers and service-token analytics totals) and Prometheus (PROMETHEUS_URL, loopback by default) with fixed queries; the request only picks 7, 30 or 90 days'],
'server/stats/open.js fetch': [1, 'open stats (/stats): fixed first-party loopback URLs only — Live (OV_LIVE_INTERNAL_URL, its public home numbers and service-token analytics totals) and Prometheus (PROMETHEUS_URL, loopback by default) with fixed queries; the request only picks 7, 30 or 90 days'],
'server/admin/service-proxies.js fetch': [1, 'the admin panel proxies: Live\'s and Chat\'s internal URLs from config, fixed path prefixes, staff only'],
'server/admin/service-proxies.js fetch': [1, 'the admin panel proxies: Live\'s and Chat\'s internal URLs from config, fixed path prefixes, staff only'],
'server/admin/review-queue.js fetch': [2, 'the reviewer queue: Wiki/Blog/News/Trade internal URLs from operator config (loopback defaults), fixed /api/v1/review-queue and review paths, ids percent-encoded, the signed-in staff member\'s own token, staff only'],
'server/admin/review-queue.js fetch': [2, 'the reviewer queue: Wiki/Blog/News/Trade internal URLs from operator config (loopback defaults), fixed /api/v1/review-queue and review paths, ids percent-encoded, the signed-in staff member\'s own token, staff only'],
- 'server/admin/ai-config-proxy.js fetch': [1, 'the owner settings proxy: the nine services\' internal URLs from config.services (operator-set, loopback defaults), fixed /internal/config and AI /api/v1/providers paths, service, namespace and key checked against the known names, owner only'],
+ 'server/admin/ai-config-proxy.js fetch': [1, 'the owner settings proxy and the Setup tab (server/admin/setup.js calls through it): the reachable services\' internal URLs from config.services (operator-set, loopback defaults), fixed /internal/config and AI /api/v1/providers paths, service, namespace and key checked against the known names, owner only'],
'server/integrations/github.js fetch': [1, 'fixed host api.github.com (asserted below)'],
'server/integrations/github.js fetch': [1, 'fixed host api.github.com (asserted below)'],
'server/notifications/email-service.js http.request': [1, 'fixed host api.resend.com'],
'server/notifications/email-service.js http.request': [1, 'fixed host api.resend.com'],
'server/actions/index.js fetch': [4, 'The Action Center: OpenVibe.Actor, OpenVibe.Codes and OpenVibe.AI only (OV_ACTOR_INTERNAL_URL, OV_CODES_INTERNAL_URL, OV_AI_INTERNAL_URL, loopback); the paths are fixed (the decide route and the cancel route), the subject is the signed-in person\'s own usr_ id, and an item id must match [A-Za-z0-9_-]{1,80}'],
'server/actions/index.js fetch': [4, 'The Action Center: OpenVibe.Actor, OpenVibe.Codes and OpenVibe.AI only (OV_ACTOR_INTERNAL_URL, OV_CODES_INTERNAL_URL, OV_AI_INTERNAL_URL, loopback); the paths are fixed (the decide route and the cancel route), the subject is the signed-in person\'s own usr_ id, and an item id must match [A-Za-z0-9_-]{1,80}'],
new file mode 100644
new file mode 100644
@@ -0,0 +1,245 @@
@@ -0,0 +1,245 @@
+'use strict';
+// The owner's Setup tab (server/admin/setup.js; OWNER-SETUP-AND-ADMIN-CLARITY-DESIGN, 2026-11-07) against fake
+// services: it lists every field carrying x-owner-input from every reachable service and Network's own to-dos,
+// Needed first, with counts that add up; a secret entered through the proxy reaches the service and never Network's
+// database or logs; Test shows the service's one-line answer and a failing test makes the row Not working until the
+// key changes; a to-do is marked done in network.setup; a service without settings is named in plain words; and
+// nobody but the owner gets the tab (no button for an admin, 403 from the API).
+// node test/setup.test.js
+const assert = require('assert');
+const fs = require('fs');
+const http = require('http');
+const path = require('path');
+const crypto = require('crypto');
+const jwt = require('jsonwebtoken');
+const { buildWorld } = require('./security-world');
+const { checkSecretsStored } = require('./security-suite');
+
+const out = (...a) => process.stdout.write(a.join(' ') + '\n');
+const NOW = new Date().toISOString();
+const ROOT = path.join(__dirname, '..');
+
+/** A fake service: records every request and answers from `routes` ({ 'METHOD /path': body | fn | [status, body] }). */
+function fake(name) {
+ const seen = [];
+ const holder = { name, seen, routes: {} };
+ holder.srv = http.createServer((req, res) => {
+ let text = '';
+ req.on('data', (d) => { text += d; });
+ req.on('end', () => {
+ let body = null;
+ try { body = text ? JSON.parse(text) : null; } catch { body = text; }
+ const rec = { method: req.method, url: req.url, headers: req.headers, body };
+ seen.push(rec);
+ const hit = holder.routes[`${req.method} ${req.url.split('?')[0]}`];
+ let answer = typeof hit === 'function' ? hit(rec) : hit;
+ let status = 200;
+ if (Array.isArray(answer)) [status, answer] = answer;
+ if (answer === undefined) { status = 404; answer = { code: 'config.route_not_found', error: 'no such route' }; }
+ res.writeHead(status, { 'content-type': status >= 400 ? 'application/problem+json' : 'application/json' });
+ res.end(JSON.stringify(answer));
+ });
+ });
+ return new Promise((resolve) => holder.srv.listen(0, '127.0.0.1', () => { holder.url = `http://127.0.0.1:${holder.srv.address().port}`; resolve(holder); }));
+}
+
+const fp = (v) => crypto.createHash('sha256').update(`fake|${v}`).digest('hex');
+const marker = (v) => ({ redacted: true, fingerprint: fp(v) });
+
+(async () => {
+ const SECRET = 'setup-sentinel-bot-token-6c2e9a-never-stored';
+ const SECRET2 = 'setup-sentinel-second-token-1b8f4d';
+ const ai = await fake('ai');
+ const chat = await fake('chat');
+ const deals = await fake('deals');
+ const unreachable = 'http://127.0.0.1:9';
+ let w = null;
+ try {
+ w = await buildWorld({
+ label: 'setup',
+ env: {
+ OV_AI_INTERNAL_URL: ai.url, OV_CHAT_INTERNAL_URL: chat.url, OV_DEALS_INTERNAL_URL: deals.url,
+ OV_BILLING_INTERNAL_URL: unreachable, OV_SERVICES_INTERNAL_URL: unreachable, OV_WATCH_INTERNAL_URL: unreachable,
+ OV_BENCH_INTERNAL_URL: unreachable, OV_REPORT_INTERNAL_URL: unreachable, OV_HOST_INTERNAL_URL: unreachable,
+ OV_CODES_INTERNAL_URL: unreachable, OV_ACTOR_INTERNAL_URL: unreachable, OV_CONNECT_INTERNAL_URL: unreachable,
+ OV_WORK_INTERNAL_URL: unreachable, OV_SOURCES_INTERNAL_URL: unreachable, OV_DESIGN_INTERNAL_URL: unreachable,
+ OV_DOMAINS_INTERNAL_URL: unreachable, OV_MEDIA_INTERNAL_URL: unreachable,
+ },
+ });
+ const owner = w.users.owner;
+ const fresh = jwt.sign({ sub: owner.id, id: owner.id, username: owner.username, subject_id: owner.subject, role: owner.role, auth_time: Math.floor(Date.now() / 1000) },
+ w.keys.privateKey, { algorithm: 'RS256', issuer: w.issuer, expiresIn: '1h' });
+ const ownerCall = (method, p, body) => w.call({ authorization: `Bearer ${fresh}`, cookie: `ov_token=${fresh}` }, method, p, body);
+
+ // ── What the fakes publish ──
+ const LONG = 'A description long enough to say what it does and what the default means.';
+ // Chat: a secret bot token (Needed until set, testable), a client id (internal, optional, set) and a field
+ // without x-owner-input (never on Setup).
+ const chatState = { token: null, revision: 3 };
+ const chatSummary = () => ({
+ service: 'chat', namespace: 'chat.apps', revision: chatState.revision, sealed: true, pins: [],
+ values: { slack_client_id: '1234.5678', answer_mode: 'quiet', ...(chatState.token ? { telegram_bot_token: marker(chatState.token) } : {}) },
+ classification: { slack_client_id: 'internal', answer_mode: 'internal', ...(chatState.token ? { telegram_bot_token: 'secret' } : {}) },
+ schema: {
+ title: 'Chat apps', description: 'The bot tokens the agent uses in chat apps.', type: 'object',
+ properties: {
+ telegram_bot_token: { type: 'string', title: 'Telegram bot token', description: 'Lets agents answer in Telegram. Without it the Telegram option stays hidden.',
+ 'x-owner-input': { group: 'chat-apps', unlocks: 'Lets agents answer in Telegram.', get_it_at: 'https://core.telegram.org/bots#how-do-i-create-a-bot', get_it_how: 'Ask @BotFather for /newbot and copy the token.', test: true },
+ 'x-required-for': 'Agents in Telegram' },
+ slack_client_id: { type: 'string', title: 'Slack client id', description: LONG, 'x-owner-input': { group: 'sign-in-apps', unlocks: 'Lets people add the agent to Slack.' }, 'x-example': '1234.5678' },
+ answer_mode: { type: 'string', title: 'Answer mode', description: LONG, enum: ['quiet', 'chatty'] },
+ },
+ },
+ active: null, last_known_good: null,
+ kek: 'k1',
+ secrets: chatState.token ? { telegram_bot_token: { redacted: true, fingerprint: fp(chatState.token), hint: chatState.token.slice(-4), set_at: NOW, set_by: { type: 'user', id: owner.subject }, bound_to: null } } : {},
+ });
+ Object.assign(chat.routes, {
+ 'GET /internal/config': () => ({ namespaces: [chatSummary()] }),
+ 'GET /internal/config/chat.apps': () => chatSummary(),
+ 'PUT /internal/config/chat.apps/secrets/telegram_bot_token': (rec) => { chatState.token = rec.body.value; chatState.revision += 1; return chatSummary().secrets.telegram_bot_token; },
+ 'DELETE /internal/config/chat.apps/secrets/telegram_bot_token': () => { chatState.token = null; chatState.revision += 1; return { redacted: true, fingerprint: null, hint: null, set_at: null, set_by: null, bound_to: null }; },
+ });
+ let chatTest = { ok: false, message: 'Telegram said 401 Unauthorized:\n the token was refused' };
+ chat.routes['POST /internal/config/chat.apps/test/telegram_bot_token'] = () => chatTest;
+ // AI: a GPU key (secret, Needed, unset) and a monthly cap (a money number, optional, unset).
+ Object.assign(ai.routes, {
+ 'GET /internal/config': {
+ namespaces: [{
+ service: 'ai', namespace: 'ai.gpus', revision: 2, sealed: true, pins: [], values: {}, classification: {},
+ schema: {
+ title: 'OpenVibe GPUs', type: 'object', properties: {
+ 'vast.key': { type: 'string', title: 'Vast.ai API key', description: LONG, 'x-owner-input': { group: 'gpus', unlocks: 'Lets OpenVibe rent GPUs on Vast.ai.', get_it_at: 'https://cloud.vast.ai/account/', get_it_how: 'Create an API key with the instance permissions.' }, 'x-required-for': 'Renting GPUs' },
+ monthly_cap: { type: 'integer', title: 'Monthly GPU cap', description: LONG, 'x-unit': 'nano_usd', 'x-danger': true, 'x-owner-input': { group: 'gpus', unlocks: 'Caps what GPU rentals may cost in a month.' } },
+ region: { type: 'string', title: 'Region', description: LONG },
+ },
+ },
+ active: null, last_known_good: null,
+ }],
+ },
+ });
+ // Deals has not mounted the config routes yet.
+ deals.routes['GET /internal/config'] = [404, { code: 'not_found', error: 'Not found' }];
+
+ // ── Only the owner: a user and a plain admin get 403, and nothing reaches a service ──
+ let r = await w.call('alice', 'GET', '/api/admin/setup');
+ assert.strictEqual(r.status, 403, 'a user never gets Setup');
+ r = await w.call('staff', 'GET', '/api/admin/setup');
+ assert.strictEqual(r.status, 403, 'a plain admin never gets Setup');
+ r = await w.call('staff', 'POST', '/api/admin/setup/tasks/dmca_agent', { done: true });
+ assert.strictEqual(r.status, 403, 'a plain admin cannot mark a to-do');
+ // Only config calls count: the server's own boot-time calls (navbar copy and the like) may reach a fake too.
+ const configCalls = () => [...ai.seen, ...chat.seen, ...deals.seen].filter((x) => x.url.startsWith('/internal/config'));
+ assert.strictEqual(configCalls().length, 0, 'a refused request never reaches a service');
+
+ // ── The page: the Setup tab is the first button, owner only, and the tab is in the owner-only set ──
+ const html = fs.readFileSync(path.join(ROOT, 'public', 'admin.html'), 'utf8');
+ 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"'));
+ 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');
+
+ // ── The checklist: every x-owner-input field and Network's own to-dos, Needed first, counts that add up ──
+ r = await ownerCall('GET', '/api/admin/setup');
+ assert.strictEqual(r.status, 200, r.text);
+ const keys = r.body.rows.filter((x) => x.kind === 'key').map((x) => x.id).sort();
+ assert.deepStrictEqual(keys, ['ai/ai.gpus/monthly_cap', 'ai/ai.gpus/vast.key', 'chat/chat.apps/slack_client_id', 'chat/chat.apps/telegram_bot_token'], 'every field with x-owner-input, and only those');
+ const tasks = r.body.rows.filter((x) => x.kind === 'task').map((x) => x.key).sort();
+ assert.deepStrictEqual(tasks, ['csam_scanning', 'dmca_agent', 'dnssec_records', 'ncmec_registration', 'provider_topup', 'public_suffix_list']);
+ const rank = { needed: 0, not_working: 1, connected: 2, optional: 3 };
+ r.body.rows.forEach((row, i) => { if (i) assert.ok(rank[r.body.rows[i - 1].state] <= rank[row.state], 'rows are Needed, Not working, Connected, Optional'); });
+ assert.strictEqual(r.body.rows[0].state, 'needed', 'Needed sorts first');
+ assert.strictEqual(r.body.groups[0].rows[0].state, 'needed', 'the page opens on a group with something that needs the owner');
+ const c = r.body.counts;
+ assert.strictEqual(c.needed + c.not_working + c.connected + c.optional, c.total, 'the counts add up');
+ assert.strictEqual(c.total, r.body.rows.length);
+ assert.strictEqual(r.body.groups.reduce((n, g) => n + g.rows.length, 0), c.total, 'every row is in one group');
+ const row = (id) => r.body.rows.find((x) => x.id === id);
+ let tg = row('chat/chat.apps/telegram_bot_token');
+ assert.deepStrictEqual({ state: tg.state, required_for: tg.required_for, group: tg.group, secret: tg.secret, testable: tg.testable, set: tg.set, get_it_at: tg.get_it_at },
+ { state: 'needed', required_for: 'Agents in Telegram', group: 'chat-apps', secret: true, testable: true, set: false, get_it_at: 'https://core.telegram.org/bots#how-do-i-create-a-bot' },
+ 'an unset token an older service leaves unclassified is still taken for a secret');
+ assert.strictEqual(row('ai/ai.gpus/vast.key').secret, true, 'so is an unset key');
+ assert.strictEqual(row('chat/chat.apps/slack_client_id').secret, false, 'a client id is not');
+ assert.strictEqual(row('chat/chat.apps/slack_client_id').state, 'connected', 'a plain value that is set and never failed is Connected');
+ assert.strictEqual(row('chat/chat.apps/slack_client_id').value, '1234.5678', 'a plain value is shown');
+ assert.strictEqual(row('ai/ai.gpus/monthly_cap').state, 'optional', 'unset and nothing waits on it: Optional');
+ assert.strictEqual(row('ai/ai.gpus/monthly_cap').unit, 'nano_usd');
+ assert.strictEqual(row('ai/ai.gpus/vast.key').state, 'needed');
+ assert.strictEqual(row(`network/network.setup/ncmec_registration`).state, 'needed', 'a legal to-do that something waits on is Needed');
+ assert.strictEqual(row(`network/network.setup/dnssec_records`).state, 'optional');
+ const dealsNote = r.body.services.find((s) => s.service === 'deals');
+ assert.deepStrictEqual({ answered: dealsNote.answered, note: dealsNote.note }, { answered: false, note: 'OpenVibe.Deals has not published settings yet.' }, 'a service without the routes is named in plain words');
+ assert.strictEqual(r.body.services.find((s) => s.service === 'media').note, 'OpenVibe.Media has not published settings yet.', 'one that does not answer too');
+ assert.ok(r.body.empty_groups.some((g) => g.id === 'voices'), 'a group no service fills yet is listed as empty');
+
+ // ── A secret entered on Setup: through the proxy to the service's vault, then Connected with its hint ──
+ r = await ownerCall('PUT', '/api/admin/ai/config/chat/chat.apps/secrets/telegram_bot_token', { value: SECRET, reason: 'Entered on Setup' });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.ok(!r.text.includes(SECRET), 'the answer is the marker, never the value');
+ const put = chat.seen.filter((x) => x.method === 'PUT').at(-1);
+ assert.strictEqual(put.body.value, SECRET, 'the service gets the value once');
+ assert.strictEqual(put.headers['x-ov-fresh'], '1');
+ r = await ownerCall('GET', '/api/admin/setup');
+ tg = r.body.rows.find((x) => x.id === 'chat/chat.apps/telegram_bot_token');
+ assert.deepStrictEqual({ state: tg.state, set: tg.set, secret: tg.secret, hint: tg.hint, last_test: tg.last_test }, { state: 'connected', set: true, secret: true, hint: SECRET.slice(-4), last_test: null });
+ assert.ok(!r.text.includes(SECRET), 'Setup never shows the value');
+
+ // ── Test: the service's one-line answer; a failure makes the row Not working ──
+ r = await ownerCall('POST', '/api/admin/ai/config/chat/chat.apps/test/telegram_bot_token', {});
+ assert.strictEqual(r.status, 200, r.text);
+ assert.deepStrictEqual({ passed: r.body.passed, message: r.body.message }, { passed: false, message: 'Telegram said 401 Unauthorized: the token was refused' });
+ r = await ownerCall('GET', '/api/admin/setup');
+ tg = r.body.rows.find((x) => x.id === 'chat/chat.apps/telegram_bot_token');
+ assert.strictEqual(tg.state, 'not_working', 'a failed test is Not working');
+ assert.strictEqual(tg.last_test.message, 'Telegram said 401 Unauthorized: the token was refused', 'with the provider\'s answer in one line');
+ assert.strictEqual(r.body.counts.not_working, 1);
+ assert.strictEqual(r.body.rows.find((x) => x.state !== 'needed').id, 'chat/chat.apps/telegram_bot_token', 'Not working comes right after Needed');
+
+ // A new key: the old failure no longer counts.
+ r = await ownerCall('PUT', '/api/admin/ai/config/chat/chat.apps/secrets/telegram_bot_token', { value: SECRET2, reason: 'Replaced on Setup' });
+ assert.strictEqual(r.status, 200, r.text);
+ r = await ownerCall('GET', '/api/admin/setup');
+ tg = r.body.rows.find((x) => x.id === 'chat/chat.apps/telegram_bot_token');
+ assert.deepStrictEqual({ state: tg.state, last_test: tg.last_test }, { state: 'connected', last_test: null }, 'a replaced key is not judged by the old key\'s test');
+ chatTest = { ok: true, message: 'Telegram knows the bot @openvibe_bot.' };
+ r = await ownerCall('POST', '/api/admin/ai/config/chat/chat.apps/test/telegram_bot_token', {});
+ assert.deepStrictEqual({ passed: r.body.passed, message: r.body.message }, { passed: true, message: 'Telegram knows the bot @openvibe_bot.' });
+ r = await ownerCall('GET', '/api/admin/setup');
+ tg = r.body.rows.find((x) => x.id === 'chat/chat.apps/telegram_bot_token');
+ assert.deepStrictEqual({ state: tg.state, passed: tg.last_test.passed }, { state: 'connected', passed: true });
+
+ // ── Network's own to-dos: Mark done, kept in network.setup, and undone ──
+ r = await ownerCall('POST', '/api/admin/setup/tasks/ncmec_registration', { done: true });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.body.row.state, 'connected');
+ assert.strictEqual(r.body.row.done.by, owner.username);
+ r = await ownerCall('GET', '/api/admin/setup');
+ assert.strictEqual(r.body.rows.find((x) => x.key === 'ncmec_registration').state, 'connected', 'a to-do marked done stays done');
+ const snap = await w.db.prepare("SELECT values_json, created_by FROM config_snapshots WHERE namespace = 'network.setup' AND state = 'active'").get();
+ assert.ok(JSON.parse(snap.values_json).ncmec_registration.done_at, 'it is a revision of network.setup');
+ assert.match(String(snap.created_by), new RegExp(owner.subject), 'made by the owner');
+ r = await ownerCall('POST', '/api/admin/setup/tasks/ncmec_registration', { done: false });
+ assert.strictEqual(r.body.row.state, 'needed', 'and it can be undone');
+ r = await ownerCall('POST', '/api/admin/setup/tasks/no_such_thing', { done: true });
+ assert.strictEqual(r.status, 404);
+ r = await ownerCall('POST', '/api/admin/setup/tasks/dmca_agent', { done: 'yes' });
+ assert.strictEqual(r.status, 400);
+
+ // ── Network's own namespace reads clearly (lintSchema, openvibe-shared 4.125.0) ──
+ const sharedConfig = require('openvibe-shared/config');
+ const { TASKS_SCHEMA } = require('../server/admin/setup');
+ if (typeof sharedConfig.lintSchema === 'function') assert.deepStrictEqual(sharedConfig.lintSchema(TASKS_SCHEMA), [], 'every to-do has a title and a clear description');
+ else for (const [k, p] of Object.entries(TASKS_SCHEMA.properties)) assert.ok(p.title && p.description.length >= 30, `${k} reads clearly`);
+
+ // ── No secret in a Network table or log line ──
+ await checkSecretsStored(w, () => ({ 'setup secret': SECRET, 'setup second secret': SECRET2 }));
+
+ out('setup: all checks passed');
+ } finally {
+ if (w) await w.stop();
+ for (const f of [ai, chat, deals]) await new Promise((res) => f.srv.close(res));
+ }
+})().then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });