openvibers/openvibe.network Commits

Operator parity checklist (WS-D task 5): an admin tab and GET /api/admin/operator-checklist list every operator job the old admin did (people, site, health/storage/media, queues, moderation/money/loyalty, releases/readiness) and where it is done now: a tab here, another console, the host CLI, or an API with no console yet (shown as a gap), each with its service readiness; test: every tab exists, every console is an OpenVibe origin, every service is in the registry; scripts/operator-checklist-check.js probes the consoles live (8/8 answer)

OP OpenVibers committed 26 Sept 2026 · 15e4d887ae · 6 files, +175 −1


Changes

ddiff --git a/package.json b/package.json
iindex afa6a670..157a2bd7 100644
---- a/package.json
++++ b/package.json
@@@ -6,7 +6,7 @@
"scripts": {
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
-- "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/github-integration.test.js && node test/revocation.test.js && node test/usernames.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-manifest-fixture.test.js && node test/staff-claims.test.js && node test/profile-events.test.js && node test/grants-admin.test.js && node test/events-ops.test.js && node test/deploy-drift.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js && node test/blocks.test.js && node test/site-config.test.js && node test/perf-budget.test.js && node test/shutdown.test.js && node test/loyalty-policy.test.js && node test/themes-review.test.js && node test/page-checks.test.js",
++ "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/github-integration.test.js && node test/revocation.test.js && node test/usernames.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-manifest-fixture.test.js && node test/staff-claims.test.js && node test/profile-events.test.js && node test/grants-admin.test.js && node test/events-ops.test.js && node test/deploy-drift.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js && node test/blocks.test.js && node test/site-config.test.js && node test/perf-budget.test.js && node test/shutdown.test.js && node test/loyalty-policy.test.js && node test/themes-review.test.js && node test/page-checks.test.js && node test/operator-checklist.test.js",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
},
ddiff --git a/public/admin.html b/public/admin.html
iindex 2ea674df..6a05e229 100644
---- a/public/admin.html
++++ b/public/admin.html
@@@ -215,6 +215,7 @@ body{min-height:100%;color:var(--text);font-family:'Inter',-apple-system,BlinkMa
<button class="admin-tab-btn" onclick="showTab('modlog',this)"><i class="fa-solid fa-gavel"></i> Moderation log</button>
<button class="admin-tab-btn owner-only" onclick="showTab('grants',this)"><i class="fa-solid fa-key"></i> Service grants</button>
<button class="admin-tab-btn" onclick="showTab('themes',this)"><i class="fa-solid fa-palette"></i> Theme review</button>
++ <button class="admin-tab-btn" onclick="showTab('checklist',this)"><i class="fa-solid fa-list-check"></i> Operator checklist</button>
<button class="admin-tab-btn owner-only" onclick="showTab('eventsops',this)"><i class="fa-solid fa-route"></i> Event deliveries</button>
<button class="admin-tab-btn" onclick="showTab('analytics',this)"><i class="fa-solid fa-chart-pie"></i> Analytics</button>
<button class="admin-tab-btn owner-only" onclick="showTab('domains',this)"><i class="fa-solid fa-globe"></i> Domains</button>
@@@ -407,6 +408,12 @@ body{min-height:100%;color:var(--text);font-family:'Inter',-apple-system,BlinkMa
<div style="display:flex;justify-content:center;margin-top:12px"><button id="modlog-more" class="btn" hidden onclick="loadModLog(false)">Load more</button></div>
</div>
++ <!-- Operator parity checklist (WS-D task 5): where each operator job is done, and what has no console yet -->
++ <div class="section" id="sec-checklist">
++ <p class="muted" style="margin:0 0 10px">Every operator job the old admin did, and where it is done now: a tab here, another service's console, the host CLI, or an API that has no console yet (shown, not hidden). The dot is that service's readiness as the status page last saw it.</p>
++ <div id="checklist-content"><div class="loading">Loading...</div></div>
++ </div>
++
<!-- Community theme review (WS-E task 2): submissions stay private until approved here -->
<div class="section" id="sec-themes">
<p class="muted" style="margin:0 0 10px">Community themes waiting for review. Approving one puts it in the public catalog; rejecting needs a reason, which the author sees. Values are already limited to the shared colour tokens.</p>
@@@ -864,6 +871,7 @@ const tabLoaders = {
modlog: () => loadModLog(true),
grants: () => loadGrants(),
themes: () => loadThemeReview(),
++ checklist: () => loadOperatorChecklist(),
eventsops: () => loadDeliveries(),
analytics: loadAnalytics,
deploy: loadDeployOverview,
@@@ -3658,6 +3666,36 @@ async function replayDeliveries(sub, ids) {
try { const r = await api('/api/admin/events/replay', { method: 'POST', body: { subscription_id: sub, event_ids: eventIds } }); alert(`Queued ${r.queued}`); loadDeliveries(); }
catch (err) { alert(err.message || 'Replay failed'); }
}
++async function loadOperatorChecklist() {
++ const c = document.getElementById('checklist-content');
++ let areas = [];
++ try { areas = (await api('/api/admin/operator-checklist')).areas || []; } catch (err) { c.textContent = 'Could not load: ' + err.message; return; }
++ c.replaceChildren();
++ const colour = { ready: 'var(--success, #3ee6b0)', degraded: 'var(--warning, #f5a524)', down: 'var(--danger, #ef4444)' };
++ for (const a of areas) {
++ const h = document.createElement('h3'); h.textContent = a.area; h.style.margin = '14px 0 6px'; c.appendChild(h);
++ for (const it of a.items) {
++ const row = document.createElement('div');
++ row.style.cssText = 'display:flex;gap:10px;align-items:flex-start;flex-wrap:wrap;padding:8px 0;border-top:1px solid var(--border)';
++ const dot = document.createElement('span'); dot.title = (it.service || '') + ': ' + it.status;
++ dot.style.cssText = 'width:10px;height:10px;border-radius:50%;margin-top:6px;flex:none'; dot.style.background = colour[it.status] || 'var(--text-muted, #888)';
++ const name = document.createElement('strong'); name.textContent = it.item; name.style.minWidth = '260px';
++ const places = document.createElement('div'); places.style.cssText = 'display:flex;gap:8px;flex-wrap:wrap;align-items:center';
++ for (const w of it.where) {
++ let el;
++ if (w.kind === 'tab') { el = document.createElement('button'); el.type = 'button'; el.className = 'btn'; el.textContent = 'Open ' + w.tab; el.onclick = () => showTab(w.tab, null); }
++ else if (w.kind === 'url') { el = document.createElement('a'); el.href = w.url; el.target = '_blank'; el.rel = 'noopener'; el.textContent = w.url.replace(/^https:\/\//, ''); }
++ else { el = document.createElement('code'); el.textContent = (w.kind === 'cli' ? '$ ' : 'API ') + (w.cli || w.api); }
++ places.appendChild(el);
++ if (w.note) { const n = document.createElement('span'); n.className = 'muted'; n.textContent = '(' + w.note + ')'; places.appendChild(n); }
++ }
++ if (!it.console) { const gap = document.createElement('span'); gap.className = 'muted'; gap.textContent = 'no console yet'; gap.style.color = 'var(--warning, #f5a524)'; places.appendChild(gap); }
++ row.append(dot, name, places);
++ c.appendChild(row);
++ }
++ }
++}
++
async function loadThemeReview() {
const c = document.getElementById('themes-review-content');
let list = [];
ddiff --git a/scripts/operator-checklist-check.js b/scripts/operator-checklist-check.js
nnew file mode 100644
iindex 00000000..0b86ee13
---- /dev/null
++++ b/scripts/operator-checklist-check.js
@@@ -0,0 +1,18 @@
++#!/usr/bin/env node
++'use strict';
++// Probes every console URL in the operator checklist (WS-D task 5): any answer but 404 (or no answer)
++// passes, since most consoles ask you to sign in. Exit 1 when one is missing.
++// node scripts/operator-checklist-check.js
++const { AREAS } = require('../server/admin/operator-checklist');
++(async () => {
++ const urls = [...new Set(AREAS.flatMap((a) => a.items.flatMap((i) => i.where.filter((w) => w.kind === 'url').map((w) => w.url))))];
++ let bad = 0;
++ for (const u of urls) {
++ let code = 0;
++ try { code = (await fetch(u, { redirect: 'manual', signal: AbortSignal.timeout(8000) })).status; } catch { code = 0; }
++ const ok = code && code !== 404;
++ if (!ok) bad++;
++ console.log(`${ok ? 'ok ' : 'FAIL'} ${code || '---'} ${u}`);
++ }
++ process.exit(bad ? 1 : 0);
++})();
ddiff --git a/server/admin/operator-checklist.js b/server/admin/operator-checklist.js
nnew file mode 100644
iindex 00000000..43d833ef
---- /dev/null
++++ b/server/admin/operator-checklist.js
@@@ -0,0 +1,78 @@
++'use strict';
++/**
++ * Operator parity checklist (roadmap WS-D task 5): every operator job the old single-site admin did,
++ * and where it is done now. Each item names its places, one of:
++ *
++ * tab a tab of this admin page (admin.html showTab id)
++ * url another service's console (answered, not 404, when last checked)
++ * api an API with no console yet (a gap the checklist shows, not hides)
++ * cli the host operator CLI (ovhost) on the server
++ *
++ * `service` ties an item to a registry id so the page can show that service's readiness next to it.
++ * GET /api/admin/operator-checklist returns the list with each service's last observed status.
++ * test/operator-checklist.test.js checks every tab exists and every url is an OpenVibe https origin;
++ * scripts/operator-checklist-check.js probes the urls live.
++ */
++const AREAS = [
++ { area: 'People', items: [
++ { item: 'Users: find, view, change', where: [{ kind: 'tab', tab: 'users' }], service: 'network' },
++ { item: 'Roles and staff', where: [{ kind: 'tab', tab: 'moderators' }, { kind: 'tab', tab: 'grants', note: 'service principals (owner)' }], service: 'network' },
++ { item: 'Bans (accounts, IPs, networks)', where: [{ kind: 'tab', tab: 'bans' }, { kind: 'url', url: 'https://openvibe.live/admin', note: 'Live bans and IP ranges' }], service: 'network' },
++ { item: 'Search across the network', where: [{ kind: 'url', url: 'https://search.openvibe.network/' }], service: 'search' },
++ ] },
++ { area: 'Site', items: [
++ { item: 'Site settings (revisioned, with rollback)', where: [{ kind: 'tab', tab: 'settings' }, { kind: 'url', url: 'https://openvibe.live/admin', note: 'Live settings' }], service: 'network' },
++ { item: 'Email configuration and a test send', where: [{ kind: 'tab', tab: 'email' }], service: 'network' },
++ { item: 'Announcements and notifications', where: [{ kind: 'tab', tab: 'notifications' }], service: 'network' },
++ { item: 'Theme review', where: [{ kind: 'tab', tab: 'themes' }], service: 'network' },
++ ] },
++ { area: 'Health, storage and media', items: [
++ { item: 'Service health (readiness of every service)', where: [{ kind: 'url', url: 'https://openvibe.network/status' }], service: 'network' },
++ { item: 'Storage', where: [{ kind: 'tab', tab: 'storage' }], service: 'media' },
++ { item: 'Media operations (jobs, tiers, repairs)', where: [{ kind: 'api', api: 'OpenVibe.Media /api/v1/jobs, /internal/*', note: 'no console yet' }, { kind: 'cli', cli: 'node scripts/h15-repair.js, object-drift-report.js (Media)' }], service: 'media' },
++ ] },
++ { area: 'Queues', items: [
++ { item: 'Events: subscriptions, dead letters, replay', where: [{ kind: 'tab', tab: 'eventsops' }, { kind: 'url', url: 'https://events.openvibe.network/' }], service: 'events' },
++ { item: 'AI runs, providers and routes', where: [{ kind: 'url', url: 'https://ai.openvibe.network/' }, { kind: 'tab', tab: 'ai' }], service: 'ai' },
++ ] },
++ { area: 'Moderation, money and loyalty', items: [
++ { item: 'Moderation audit across services', where: [{ kind: 'tab', tab: 'modlog' }], service: 'network' },
++ { item: 'Chat logs and chat moderation', where: [{ kind: 'tab', tab: 'chat-logs' }], service: 'chat' },
++ { item: 'Community moderation', where: [{ kind: 'api', api: 'OpenVibe.Community staff routes', note: 'no console yet' }], service: 'community' },
++ { item: 'Payments, cashouts and the money freeze', where: [{ kind: 'url', url: 'https://billing.openvibe.network/', note: 'Billing console' }, { kind: 'tab', tab: 'cashouts' }, { kind: 'tab', tab: 'payments' }], service: 'billing' },
++ { item: 'Loyalty (OpenCoins, channel points)', where: [{ kind: 'api', api: 'live.loyalty user module; Network /internal/coins/*', note: 'summaries only; loyalty is never money' }], service: 'network' },
++ { item: 'Developer apps and trust (Codes)', where: [{ kind: 'url', url: 'https://openvibe.codes/staff' }], service: 'codes' },
++ ] },
++ { area: 'Releases and readiness', items: [
++ { item: 'Migration and readiness', where: [{ kind: 'url', url: 'https://openvibe.network/status' }, { kind: 'cli', cli: 'ovhost status, ovhost validate <service>' }], service: 'host' },
++ { item: 'Compatibility status (contracts, pins, the register)', where: [{ kind: 'url', url: 'https://openvibe.codes/docs' }, { kind: 'cli', cli: 'ovhost validate <service> (contracts range)' }], service: 'codes' },
++ { item: 'Deploys', where: [{ kind: 'tab', tab: 'deploy' }, { kind: 'cli', cli: 'ovhost deploy <service>' }], service: 'host' },
++ { item: 'Domains and certificates', where: [{ kind: 'tab', tab: 'domains' }, { kind: 'cli', cli: 'ovhost certs' }], service: 'host' },
++ ] },
++];
++
++/** The checklist with each service's last observed readiness (from the status rows, if given). */
++function checklist(statusRows = []) {
++ const byId = new Map((statusRows || []).map((r) => [r.id || r.service, r]));
++ return AREAS.map((a) => ({
++ area: a.area,
++ items: a.items.map((it) => {
++ const st = it.service ? byId.get(it.service) : null;
++ const hasConsole = it.where.some((w) => w.kind === 'tab' || w.kind === 'url');
++ return { ...it, console: hasConsole, status: st ? (st.status || (st.runtime && st.runtime.status) || 'unknown') : 'unknown' };
++ }),
++ }));
++}
++
++function router({ statusRows = () => [] } = {}) {
++ const express = require('express');
++ const r = express.Router();
++ r.get('/', (req, res) => {
++ let rows = [];
++ try { rows = statusRows() || []; } catch { rows = []; }
++ res.set('Cache-Control', 'private, no-store').json({ areas: checklist(rows) });
++ });
++ return r;
++}
++
++module.exports = { AREAS, checklist, router };
ddiff --git a/server/index.js b/server/index.js
iindex 3f874c67..938255da 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -574,6 +574,8 @@ app.use('/api/admin/events', requireAuth, require('./admin/events-ops').createEv
app.use('/api/admin/grants', requireAuth, require('./identity/grants-admin').router(db));
// Community theme review queue (WS-E task 2): submissions stay private until an admin approves them.
app.use('/api/admin/themes', requireAuth, requireAdmin, require('./themes/routes').reviewRouter());
++// Operator parity checklist (WS-D task 5): where each operator job is done now, with each service's readiness.
++app.use('/api/admin/operator-checklist', requireAuth, requireAdmin, require('./admin/operator-checklist').router({ statusRows: () => require('./status/routes').rows(ecosystem) }));
app.use('/api/admin', createAdminRoutes(db, notificationService, emailService, requireAuth));
// Analytics admin API
ddiff --git a/test/operator-checklist.test.js b/test/operator-checklist.test.js
nnew file mode 100644
iindex 00000000..e72280dd
---- /dev/null
++++ b/test/operator-checklist.test.js
@@@ -0,0 +1,38 @@
++'use strict';
++// Operator parity checklist (WS-D task 5): every item names at least one place; every tab it opens
++// exists on the admin page (a button and a loader); every console is an OpenVibe https origin; every
++// service is in the registry; items without a tab or console are marked as gaps, not hidden.
++// node test/operator-checklist.test.js
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const { services } = require('openvibe-contracts');
++const { AREAS, checklist } = require('../server/admin/operator-checklist');
++
++const html = fs.readFileSync(path.join(__dirname, '..', 'public', 'admin.html'), 'utf8');
++const buttons = new Set([...html.matchAll(/showTab\('([a-z-]+)'/g)].map((m) => m[1]));
++const block = html.slice(html.indexOf('const tabLoaders = {'), html.indexOf('};', html.indexOf('const tabLoaders = {')));
++const loaders = new Set([...block.matchAll(/^\s{4}'?([a-z-]+)'?:/gm)].map((m) => m[1]));
++const ids = new Set(services.manifests.map((m) => m.id));
++const plan = ['users', 'search', 'roles', 'bans', 'settings', 'email', 'announcements', 'health', 'storage', 'media', 'queues', 'moderation', 'billing', 'loyalty', 'migration', 'compatibility', 'deploy', 'certificates'];
++const text = JSON.stringify(AREAS).toLowerCase();
++for (const word of plan) assert.ok(text.includes(word.replace('queues', 'dead letters').replace('announcements', 'announcements')), `the checklist covers ${word}`);
++
++let items = 0;
++for (const a of AREAS) for (const it of a.items) {
++ items++;
++ assert.ok(it.where.length, `${it.item}: somewhere to do it`);
++ if (it.service) assert.ok(ids.has(it.service), `${it.item}: ${it.service} is a registry service`);
++ for (const w of it.where) {
++ if (w.kind === 'tab') { assert.ok(buttons.has(w.tab), `${it.item}: tab ${w.tab} has a button`); assert.ok(loaders.has(w.tab), `${it.item}: tab ${w.tab} has a loader`); }
++ else if (w.kind === 'url') assert.match(w.url, /^https:\/\/([a-z0-9-]+\.)*openvibe\.[a-z]+(\/|$)/, `${it.item}: ${w.url} is an OpenVibe origin`);
++ else assert.ok(['api', 'cli'].includes(w.kind) && (w.api || w.cli), `${it.item}: ${w.kind}`);
++ }
++}
++assert.ok(items >= 20);
++const out = checklist([{ id: 'billing', status: 'ready' }]);
++const pay = out.flatMap((a) => a.items).find((i) => /Payments/.test(i.item));
++assert.deepStrictEqual([pay.status, pay.console], ['ready', true]);
++const community = out.flatMap((a) => a.items).find((i) => /Community moderation/.test(i.item));
++assert.deepStrictEqual([community.status, community.console], ['unknown', false], 'a gap is shown as a gap');
++console.log(`operator checklist: all checks passed (${items} items)`);