openvibers/openvibe.network Commits

The owner's Setup tab, and the AI tab in plain words (#610)

OP OpenVibers committed 6 days ago · 30f48c5a5f · 19 files, +1857 −228

* The owner's Setup tab, and the AI tab in plain words

Setup (/admin#setup, owner only, the first tab): one checklist of every key, account and cap only
the owner can give, read from every reachable service's config fields that carry x-owner-input,
plus Network's own to-dos (network.setup, Mark done). Needed first, then Not working, Connected and
Optional, with a counted summary; each row says what it turns on, where to get it, and offers a
write-only input, Test and Remove through the existing owner settings proxy.

The proxy reaches seventeen services (chat, connect, deals, work, sources, design, domains and
media added), forwards each secret's hint and date, and adds the test route, whose one-line answer
is kept as an ai_config_test audit row with a digest of what was tried. Network holds
<service>.config.manage for the eight once the pinned Contracts (0.308.0) knows them.

The AI tab uses the design's words: section intros, Key and Health lines, billing and access in
words, schema titles with the machine key small and grey, choices from x-enum-labels, a counted
Advanced settings fold for x-advanced fields and raw JSON, objects as small forms, versions by name.
A glossary explains the words that must stay (/admin#glossary). Phone controls are 44 px; status
text uses the themes' text colours.

* Setup and AI tabs: plain settings read Set, Mark done is a normal button, non-AI keys stay off the AI tab

A switch that is Off read "Connected"; it now reads Set (keys and accounts stay Connected, tasks Done).
Mark done stretched across the row. Codes' publishing tokens showed under "Other services' AI settings":
a namespace made only of non-AI owner inputs is Setup's alone, and the section says so.

Changes

ddiff --git a/README.md b/README.md
iindex dc0f5464..e4dbc5ac 100644
---- a/README.md
++++ b/README.md
@@@ -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).
ddiff --git a/public/admin.html b/public/admin.html
iindex 9d5cb72d..7f424442 100644
---- a/public/admin.html
++++ b/public/admin.html
@@@ -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>
ddiff --git a/public/css/admin.css b/public/css/admin.css
iindex f9b16512..2774242b 100644
---- a/public/css/admin.css
++++ b/public/css/admin.css
@@@ -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}
++}
ddiff --git a/public/js/admin/ai.js b/public/js/admin/ai.js
iindex cacc5fa0..5e30d736 100644
---- a/public/js/admin/ai.js
++++ b/public/js/admin/ai.js
@@@ -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>';
});
ddiff --git a/public/js/admin/boot.js b/public/js/admin/boot.js
iindex 1a1e4f62..96c1fcc7 100644
---- a/public/js/admin/boot.js
++++ b/public/js/admin/boot.js
@@@ -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,
ddiff --git a/public/js/admin/core.js b/public/js/admin/core.js
iindex 072d80a4..347015ea 100644
---- a/public/js/admin/core.js
++++ b/public/js/admin/core.js
@@@ -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';
ddiff --git a/public/js/admin/glossary.js b/public/js/admin/glossary.js
nnew file mode 100644
iindex 00000000..4c3d6a1e
---- /dev/null
++++ b/public/js/admin/glossary.js
@@@ -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);
++}
ddiff --git a/public/js/admin/setup.js b/public/js/admin/setup.js
nnew file mode 100644
iindex 00000000..494a7a36
---- /dev/null
++++ b/public/js/admin/setup.js
@@@ -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;
++ }
++}
ddiff --git a/server/admin/ai-config-proxy.js b/server/admin/ai-config-proxy.js
iindex a4ab7fdf..118943e2 100644
---- a/server/admin/ai-config-proxy.js
++++ b/server/admin/ai-config-proxy.js
@@@ -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,
};
ddiff --git a/server/admin/routes.js b/server/admin/routes.js
iindex 87392a5f..6775e874 100644
---- a/server/admin/routes.js
++++ b/server/admin/routes.js
@@@ -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;
};
ddiff --git a/server/admin/setup.js b/server/admin/setup.js
nnew file mode 100644
iindex 00000000..9e48c6b7
---- /dev/null
++++ b/server/admin/setup.js
@@@ -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 };
ddiff --git a/server/config.js b/server/config.js
iindex fc3a8263..473dd5e0 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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',
++ },
},
};
ddiff --git a/server/identity/principals.js b/server/identity/principals.js
iindex 6b951f5e..1296781b 100644
---- a/server/identity/principals.js
++++ b/server/identity/principals.js
@@@ -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', []],
ddiff --git a/test/admin-ai-clarity.test.js b/test/admin-ai-clarity.test.js
nnew file mode 100644
iindex 00000000..58bfd2ce
---- /dev/null
++++ b/test/admin-ai-clarity.test.js
@@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;'),
++ 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');
ddiff --git a/test/admin-modules.test.js b/test/admin-modules.test.js
iindex e1d377de..93f23e85 100644
---- a/test/admin-modules.test.js
++++ b/test/admin-modules.test.js
@@@ -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();
// 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]);
ddiff --git a/test/admin-setup-ui.test.js b/test/admin-setup-ui.test.js
nnew file mode 100644
iindex 00000000..c72438fe
---- /dev/null
++++ b/test/admin-setup-ui.test.js
@@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;'),
++ 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');
ddiff --git a/test/ai-config-proxy.test.js b/test/ai-config-proxy.test.js
iindex c75f4a16..05c5114e 100644
---- a/test/ai-config-proxy.test.js
++++ b/test/ai-config-proxy.test.js
@@@ -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.js
const 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');
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 3a136b10..0516006d 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -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}'],
ddiff --git a/test/setup.test.js b/test/setup.test.js
nnew file mode 100644
iindex 00000000..32b67a23
---- /dev/null
++++ b/test/setup.test.js
@@@ -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); });