openvibers/openvibe.network Commits
Admin reviewer queue: one page across Wiki, Blog, News and Trade (#567)
Changes
@@@ -91,6 +91,11 @@ OV_TOOLS_INTERNAL_URL=http://127.0.0.1:4001OV_GAMES_INTERNAL_URL=http://127.0.0.1:8000OV_MEDIA_INTERNAL_URL=http://127.0.0.1:4100OV_CHAT_INTERNAL_URL=http://127.0.0.1:4400++# The publication reviewer queue (/admin/review): each product's staff review-queue endpoint.++OV_WIKI_INTERNAL_URL=http://127.0.0.1:4800++OV_BLOG_INTERNAL_URL=http://127.0.0.1:4810++OV_NEWS_INTERNAL_URL=http://127.0.0.1:4820++OV_TRADE_INTERNAL_URL=http://127.0.0.1:4860# Optional: SSH admin-tab context (non-secret display values)#SSH_SERVER_HOST=@@@ -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.++- **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).- **Public discovery** — `/.well-known/openvibe`, `/api/v1/registry/*` and `/contracts/*.json` (services, capabilities, contract schemas) answer any origin with `Access-Control-Allow-Origin: *`, preflight included, so a browser app can discover the platform directly. Every other route keeps the first-party CORS allow-list (`server/public-cors.js`).@@@ -48,6 +48,7 @@<div class="section-tabs-wrap"><div class="section-tabs" id="main-tabs"><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><button class="admin-tab-btn" onclick="showTab('streams',this)"><i class="fa-solid fa-broadcast-tower"></i> Streams</button><button class="admin-tab-btn" onclick="showTab('lineage',this)"><i class="fa-solid fa-diagram-project"></i> Lineage</button>nnew file mode 100644@@@ -0,0 +1,308 @@++'use strict';++/**++ * The publication reviewer queue (plan 2026-10-22 §"Publications", lines 947-952): one page for++ * everything OpenVibe.Wiki, Blog, News and Trade hold for a person's review, oldest first.++ *++ * Why here: OpenVibe.Publishing is code, never a runtime (its README: no port, domain, database or++ * API of its own), so the queue is hosted by the service that already gives staff one console for++ * every product — Network's admin — and uses the products' own gate reasons and review routes.++ *++ * GET /admin/review the queue, grouped by site, noindex, staff only++ * POST /admin/review/action approve (indexable) or keep hidden, through the owning service's own++ * review route. The product checks the person's role again and writes++ * its own append-only review log; Network never invents a decision.++ *++ * Each service exposes GET /api/v1/review-queue to a staff/editor user token (its own check), and++ * the existing review routes do the work. The token forwarded is the signed-in person's, renewed by++ * Network's requireAuth, so a role granted a moment ago counts upstream.++ */++const crypto = require('crypto');++const express = require('express');++const contracts = require('openvibe-contracts');++const kit = require('openvibe-shared/kit');++const shell = require('openvibe-shared/shell');++++const SITES = [++ { id: 'wiki', name: 'OpenVibe.Wiki', env: 'OV_WIKI_INTERNAL_URL', fallback: 'http://127.0.0.1:4800' },++ { id: 'blog', name: 'OpenVibe.Blog', env: 'OV_BLOG_INTERNAL_URL', fallback: 'http://127.0.0.1:4810' },++ { id: 'news', name: 'OpenVibe.News', env: 'OV_NEWS_INTERNAL_URL', fallback: 'http://127.0.0.1:4820' },++ { id: 'trade', name: 'OpenVibe.Trade', env: 'OV_TRADE_INTERNAL_URL', fallback: 'http://127.0.0.1:4860' },++];++const SITE_BY_ID = new Map(SITES.map((s) => [s.id, s]));++const KIND_LABEL = Object.freeze({++ wiki_page: 'Wiki page', blog_draft: 'Blog draft', blog_post: 'Blog post',++ ai_brief: 'AI brief', news_story: 'News story', company_page: 'Company page',++});++const KINDS = new Set(Object.keys(KIND_LABEL));++const DECISIONS = new Set(['approved', 'rejected']);++const FIRST = 6; // per site before the rest fold behind "More (N)"++const MAX_ITEMS = 2000; // a service that answers too much cannot exhaust the page++++const esc = kit.esc;++const ageText = (iso, now) => {++ const ms = Date.parse(iso);++ if (!Number.isFinite(ms)) return '';++ const mins = Math.max(0, Math.floor((now - ms) / 60000));++ if (mins < 60) return `${mins || 1} min`;++ const hours = Math.floor(mins / 60);++ if (hours < 48) return `${hours} h`;++ const days = Math.floor(hours / 24);++ if (days < 60) return `${days} day${days === 1 ? '' : 's'}`;++ const months = Math.floor(days / 30);++ return `${months} month${months === 1 ? '' : 's'}`;++};++++/** A URL kept only when it is an https URL or a same-site path. */++const safeUrl = (u) => {++ const s = String(u == null ? '' : u).trim();++ if (/^https:\/\//i.test(s)) return s;++ if (/^\/[^/]/.test(s)) return s;++ return '#';++};++++function serviceBase(site, env = process.env) {++ return String(env[site.env] || site.fallback).replace(/\/+$/, '');++}++++/** One item from a service, defensively shaped: a bad row is skipped, never rendered as trusted. */++function itemOf(raw, site, now) {++ if (!raw || typeof raw !== 'object') return null;++ if (raw.site !== site.id || !KINDS.has(raw.kind)) return null;++ if (typeof raw.id !== 'string' || !raw.id || raw.id.length > 200) return null;++ if (typeof raw.title !== 'string' || !raw.title) return null;++ const createdAt = new Date(raw.createdAt);++ if (!Number.isFinite(createdAt.getTime())) return null;++ const revision = raw.revision == null ? null : Number(raw.revision);++ if (revision != null && !Number.isInteger(revision)) return null;++ const reasons = Array.isArray(raw.reasons) ? raw.reasons.filter((r) => r && typeof r.text === 'string').slice(0, 6).map((r) => ({ code: String(r.code || ''), text: r.text.slice(0, 300) })) : [];++ return {++ site: site.id, kind: raw.kind, id: raw.id, title: raw.title.slice(0, 300),++ revision, state: typeof raw.state === 'string' ? raw.state : null, ref: typeof raw.ref === 'string' ? raw.ref : null,++ url: safeUrl(raw.url), editUrl: raw.editUrl == null ? null : safeUrl(raw.editUrl),++ createdAt: createdAt.toISOString(), age: ageText(createdAt.toISOString(), now),++ reasons: reasons.length ? reasons : [{ code: 'noindex_requested', text: 'Waiting for a person\'s review.' }],++ };++}++++/** Fetch every site's queue in parallel; a down service becomes a visible section error, not a blank page. */++async function collect({ token, fetchImpl = globalThis.fetch, now = Date.now(), timeoutMs = 5000, env = process.env } = {}) {++ const errors = [];++ const items = [];++ await Promise.all(SITES.map(async (site) => {++ const base = serviceBase(site, env);++ try {++ const res = await fetchImpl(`${base}/api/v1/review-queue`, {++ headers: { Accept: 'application/json', Authorization: `Bearer ${token}` },++ redirect: 'error', signal: AbortSignal.timeout(timeoutMs),++ });++ if (!res.ok) { errors.push({ site: site.id, name: site.name, status: res.status }); return; }++ const data = await res.json();++ const rows = data && Array.isArray(data.items) ? data.items.slice(0, MAX_ITEMS) : [];++ for (const row of rows) { const item = itemOf(row, site, now); if (item) items.push(item); }++ } catch {++ errors.push({ site: site.id, name: site.name, status: 0 });++ }++ }));++ items.sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt) || (a.site < b.site ? -1 : a.site > b.site ? 1 : 0));++ return { items, errors, counts: Object.fromEntries(SITES.map((s) => [s.id, items.filter((i) => i.site === s.id).length])) };++}++++function kindOf(kind) { return KIND_LABEL[kind] || kind; }++++function actionForms(item, csrf) {++ const base = { csrf, site: item.site, kind: item.kind, id: item.id, revision: item.revision == null ? '' : String(item.revision), ref: item.ref || '', state: item.state || '' };++ const approve = kit.action({ action: '/admin/review/action', hidden: { ...base, decision: 'approved' }, label: 'Approve (indexable)', icon: 'check', kind: 'primary', small: true });++ const keep = kit.action({ action: '/admin/review/action', hidden: { ...base, decision: 'rejected' }, label: 'Keep hidden', icon: 'ban', kind: 'quiet', small: true });++ return `<span class="rq-actions">${approve}${keep}</span>`;++}++++function itemRow(item, csrf) {++ const href = item.editUrl || item.url;++ const title = `<a href="${esc(safeUrl(href))}">${esc(item.title)}</a>`;++ const why = item.reasons.map((r) => r.text).join(' · ');++ return kit.row({++ titleHtml: title,++ badge: kit.badge(kindOf(item.kind)),++ meta: item.age ? `${item.age} waiting` : '',++ metaIso: item.createdAt,++ sub: why,++ trail: actionForms(item, csrf),++ });++}++++function siteSection(site, items, csrf) {++ const list = items.length ? kit.list(items.slice(0, FIRST).map((i) => itemRow(i, csrf))) : kit.empty({ title: 'Nothing is waiting', text: `${site.name} has no page waiting for a person's review.` });++ const rest = items.slice(FIRST);++ const more = rest.length ? kit.fold({++ id: `rq-${site.id}-more`,++ title: `More (${rest.length})`,++ meta: `${items.length} in all`,++ body: kit.list(rest.map((i) => itemRow(i, csrf))),++ }) : '';++ return kit.section({ id: `rq-${site.id}`, title: site.name, count: items.length, note: items.length ? 'Oldest first' : '', body: list + more });++}++++function renderPage({ items, errors, counts, csrf, config, now = Date.now(), saved = null }) {++ const total = items.length;++ // Stacked sections, oldest waiting first; a site with nothing waiting sits at the end.++ const failed = new Set(errors.map((e) => e.site));++ const grouped = SITES.filter((site) => !failed.has(site.id)).map((site) => ({ site, items: items.filter((i) => i.site === site.id) }))++ .sort((a, b) => (a.items[0] ? Date.parse(a.items[0].createdAt) : Infinity) - (b.items[0] ? Date.parse(b.items[0].createdAt) : Infinity));++ const stats = kit.stats([++ [total, 'waiting for review'],++ ...SITES.map((s) => [counts[s.id], s.name.replace('OpenVibe.', '')]),++ ], { label: 'Review queue numbers' });++ const notices = [++ saved && saved.error ? kit.notice({ kind: 'error', text: saved.error }) : '',++ saved && saved.ok ? kit.notice({ kind: 'ok', text: saved.ok }) : '',++ ...errors.map((e) => kit.notice({ kind: 'warn', text: `${e.name} did not answer${e.status ? ` (${e.status})` : ''}; its section is missing.` })),++ ].join('');++ const empty = !total && !errors.length++ ? kit.empty({ title: 'Nothing is waiting', text: 'Every publication has a person\'s decision for its AI-written text.', icon: 'check' })++ : '';++ const body = `<main class="rq">++${kit.head({ title: 'Review queue', sub: 'Everything waiting for a person, oldest first.', back: { href: '/admin', label: 'Admin' } })}++${notices}++${stats}++${empty || grouped.map(({ site, items: rows }) => siteSection(site, rows, csrf)).join('\n')}++</main>`;++ return shell.page({++ name: 'network', service: 'network',++ title: 'Review queue', siteName: 'OpenVibe.Network',++ description: 'Every Wiki page, Blog draft, AI brief and company page waiting for a person\'s review.',++ canonical: `${String(config.baseUrl || 'https://openvibe.network').replace(/\/+$/, '')}/admin/review`,++ robots: 'noindex, nofollow',++ head: `<link rel="stylesheet" href="/shared/kit.css">++<script src="/shared/kit-web.js" defer></script>++<style>++.rq{max-width:900px;margin:0 auto;padding:16px}++.rq .ovk-head{margin-bottom:12px}++.rq-actions{display:flex;gap:8px;flex-wrap:wrap;justify-content:flex-end}++.rq-actions form{margin:0}++.rq .ovk-row-trail{max-width:100%}++@media (max-width:599px){++.rq .ovk-stats{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));margin:12px 0;padding:0;overflow:visible;gap:8px}++.rq .ovk-row--static{flex-direction:column;align-items:stretch;gap:6px}++.rq .ovk-row-main{width:100%}++.rq .ovk-row-trail{width:100%}++.rq .ovk-row-top{flex-wrap:wrap}++.rq .ovk-row-top strong{flex:1 1 100%;white-space:normal;overflow:visible;text-overflow:clip}++.rq .ovk-row-top strong a{display:block;overflow-wrap:anywhere}++.rq .ovk-row-sub>span:first-child{white-space:normal;overflow:visible}++.rq-actions{justify-content:flex-start}++}++</style>`,++ body,++ navLinks: [{ label: 'Admin', href: '/admin' }],++ navbar: { service: 'network' },++ footer: { service: 'network', variant: 'compact' },++ });++}++++/** HMAC over the person, purpose and expiry; the key is Network's signing key (never a new secret). */++function csrfToken(user, key, now = Date.now()) {++ const exp = Math.floor(now / 1000) + 3600;++ const body = `${user.id}.${exp}`;++ return `${body}.${crypto.createHmac('sha256', key).update(`review-queue:${body}`).digest('base64url')}`;++}++function csrfOk(token, user, key, now = Date.now()) {++ const parts = String(token || '').split('.');++ if (parts.length !== 3 || String(parts[0]) !== String(user.id)) return false;++ const exp = Number(parts[1]);++ if (!Number.isInteger(exp) || exp < Math.floor(now / 1000)) return false;++ const want = crypto.createHmac('sha256', key).update(`review-queue:${parts[0]}.${parts[1]}`).digest('base64url');++ const a = Buffer.from(String(parts[2])), b = Buffer.from(want);++ return a.length === b.length && crypto.timingSafeEqual(a, b);++}++++function createReviewQueueRouter({ requireAuth, config = {}, privateKey, fetchImpl = globalThis.fetch, now = Date.now, timeoutMs = 5000, env = process.env }) {++ const router = express.Router();++ const form = express.urlencoded({ extended: false, limit: '8kb' });++ const editorial = (req) => Boolean(req.user && req.tokenClaims && contracts.staff.can(req.tokenClaims, 'staff.editorial.manage'));++ const refuse = (req, res) => {++ res.status(403).type('html').set('Cache-Control', 'no-store').send(shell.page({++ name: 'network', service: 'network', title: 'Staff only', robots: 'noindex, nofollow',++ canonical: `${String(config.baseUrl || 'https://openvibe.network').replace(/\/+$/, '')}/admin/review`,++ head: '<link rel="stylesheet" href="/shared/kit.css">',++ body: `<main class="rq"><h1>Staff only</h1><p>Only OpenVibe editorial staff review the publications.</p><p><a href="/">OpenVibe.Network</a></p></main>`,++ navLinks: [{ label: 'Admin', href: '/admin' }],++ }));++ };++ const guard = [requireAuth, (req, res, next) => (editorial(req) ? next() : refuse(req, res))];++++ router.get('/', guard, async (req, res, next) => {++ try {++ const key = (req.app.locals && req.app.locals.privateKey) || privateKey;++ if (!key) throw new Error('the signing key is not loaded');++ const got = await collect({ token: req.token, fetchImpl, now: now(), timeoutMs, env });++ const saved = req.query.saved ? { ok: String(req.query.saved).slice(0, 300) } : null;++ const failed = req.query.error ? { error: String(req.query.error).slice(0, 300) } : null;++ res.set('Cache-Control', 'no-store').type('html').send(renderPage({++ ...got, csrf: csrfToken(req.user, key, now()), config, now: now(), saved: saved || failed,++ }));++ } catch (err) {++ next(err);++ }++ });++++ router.post('/action', ...guard, form, async (req, res, next) => {++ try {++ const key = (req.app.locals && req.app.locals.privateKey) || privateKey;++ if (!key || !csrfOk(req.body && req.body.csrf, req.user, key, now())) {++ return res.redirect(303, '/admin/review?error=' + encodeURIComponent('That form expired. Reload the queue and try again.'));++ }++ const b = req.body || {};++ const site = SITE_BY_ID.get(String(b.site || ''));++ const kind = String(b.kind || '');++ const id = String(b.id || '');++ const decision = String(b.decision || '');++ const revision = Number(b.revision);++ const ref = String(b.ref || '');++ if (!site || !KINDS.has(kind) || !id || !DECISIONS.has(decision) || (b.revision !== '' && !Number.isInteger(revision))) {++ return res.redirect(303, '/admin/review?error=' + encodeURIComponent('That item could not be read. Reload the queue and try again.'));++ }++ const base = serviceBase(site, env);++ const body = { decision, note: '' };++ let path;++ if (site.id === 'wiki' && kind === 'wiki_page' && Number.isInteger(revision)) path = `/api/v1/pages/${encodeURIComponent(id)}/revisions/${revision}/review`;++ else if (site.id === 'blog' && (kind === 'blog_draft' || kind === 'blog_post') && Number.isInteger(revision)) path = `/api/v1/posts/${encodeURIComponent(id)}/reviews`;++ else if (site.id === 'news' && kind === 'news_story' && Number.isInteger(revision)) path = `/api/v1/stories/${encodeURIComponent(id)}/reviews`;++ else if (site.id === 'news' && kind === 'ai_brief') path = `/api/v1/briefs/${encodeURIComponent(id)}/reviews`;++ else if (site.id === 'trade' && kind === 'company_page' && Number.isInteger(revision) && ref) path = `/api/v1/instruments/${encodeURIComponent(ref)}/context/revisions/${revision}/review`;++ else return res.redirect(303, '/admin/review?error=' + encodeURIComponent('That item has no action for its kind.'));++++ if (site.id === 'news' && kind === 'ai_brief') { body.kind = 'summary'; body.tone = 'neutral'; }++ if (site.id !== 'news' || kind !== 'ai_brief') body.revision = revision;++ const call = async (p, payload) => {++ const r = await fetchImpl(`${base}${p}`, {++ method: 'POST', redirect: 'error',++ headers: { 'Content-Type': 'application/json', Accept: 'application/json', Authorization: `Bearer ${req.token}` },++ body: JSON.stringify(payload), signal: AbortSignal.timeout(timeoutMs),++ });++ if (!r.ok) {++ const problem = await r.json().catch(() => null);++ throw new Error((problem && (problem.detail || problem.title)) || `the service answered ${r.status}`);++ }++ return await r.json().catch(() => ({}));++ };++ await call(path, body);++ // A draft is reviewed and then published: the review alone leaves it a draft (still noindex).++ if (decision === 'approved' && site.id === 'blog' && kind === 'blog_draft') await call(`/api/v1/posts/${encodeURIComponent(id)}/publish`, { revision });++ if (decision === 'approved' && site.id === 'news' && kind === 'news_story' && b.state !== 'published') await call(`/api/v1/stories/${encodeURIComponent(id)}/publish`, { revision });++ const what = decision === 'approved' ? 'Approved' : 'Kept hidden';++ res.redirect(303, '/admin/review?saved=' + encodeURIComponent(`${what}: ${site.name}.`));++ } catch (err) {++ next(err);++ }++ });++++ // A failed upstream write shows on the page, not as a stack trace.++ router.use((err, req, res, next) => {++ if (!err || res.headersSent) return next(err);++ res.redirect(303, '/admin/review?error=' + encodeURIComponent(String(err.message || 'The action did not complete').slice(0, 300)));++ });++++ return router;++}++++module.exports = { SITES, KIND_LABEL, collect, itemOf, renderPage, createReviewQueueRouter, csrfToken, csrfOk, ageText };@@@ -1025,6 +1025,10 @@ app.get(['/login', '/forgot-password', '/reset-password'], (req, res) => {res.sendFile(path.join(__dirname, '..', 'public', 'login.html'));});++// The publication reviewer queue: a server-rendered staff page (openvibe-shared/kit) before the++// admin SPA's catch-all. It asks each publication's own review-queue endpoint with the person's token.++app.use('/admin/review', require('./admin/review-queue').createReviewQueueRouter({ requireAuth, config, privateKey: app.locals.privateKey }));++// Admin panel — serve the SPA for /admin and any client-routed sub-path// (e.g. /admin/settings, /admin/analytics/overview). Auth is checked client-side.app.get(['/admin', '/admin/*'], (req, res) => {nnew file mode 100644@@@ -0,0 +1,134 @@++'use strict';++/**++ * The publication reviewer queue (server/admin/review-queue.js): one staff page across Wiki, Blog,++ * News and Trade, oldest first, grouped by site with a "More (N)" fold; approve/keep-hidden call the++ * owning service's own review route (and publish a draft after approval), with the person's token.++ */++const assert = require('assert');++const http = require('http');++const express = require('express');++const contracts = require('openvibe-contracts');++const { createReviewQueueRouter, collect, csrfToken, csrfOk } = require('../server/admin/review-queue');++++const ADMIN_CAPS = contracts.staff.capabilitiesOf({ role: 'admin' });++++function upstream(label, items) {++ const seen = [];++ const srv = http.createServer((req, res) => {++ let body = '';++ req.on('data', (d) => { body += d; });++ req.on('end', () => {++ seen.push({ method: req.method, url: req.url, auth: req.headers.authorization || null, body: body ? JSON.parse(body) : null });++ if (req.url === '/api/v1/review-queue') {++ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ items: items.map((i) => ({ ...i, site: label })) }));++ } else {++ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true }));++ }++ });++ });++ return new Promise((resolve) => srv.listen(0, '127.0.0.1', () => resolve({ srv, seen, url: `http://127.0.0.1:${srv.address().port}` })));++}++++const item = (id, kind, createdAt, extra = {}) => ({++ id, kind, title: `Title ${id}`, revision: 2, url: `/x/${id}`, createdAt,++ reasons: [{ code: 'ai_generated_unreviewed', text: 'AI-written and not reviewed by a person.' }],++ review: { method: 'POST', path: '/ignored' }, ...extra,++});++++(async () => {++ const wiki = await upstream('wiki', [item('pg_a', 'wiki_page', '2026-10-01T00:00:00Z'), item('pg_b', 'wiki_page', '2026-10-03T00:00:00Z')]);++ const blog = await upstream('blog', [item('pst_a', 'blog_draft', '2026-10-02T00:00:00Z'), item('pst_b', 'blog_draft', '2026-10-04T00:00:00Z'), item('pst_c', 'blog_draft', '2026-10-05T00:00:00Z'), item('pst_d', 'blog_draft', '2026-10-06T00:00:00Z'), item('pst_e', 'blog_draft', '2026-10-07T00:00:00Z'), item('pst_f', 'blog_draft', '2026-10-08T00:00:00Z'), item('pst_g', 'blog_draft', '2026-10-09T00:00:00Z')]);++ const news = await upstream('news', [item('clu_a', 'ai_brief', '2026-09-30T00:00:00Z')]);++ const trade = await upstream('trade', [item('ins_a', 'company_page', '2026-09-29T00:00:00Z', { ref: 'ACME' })]);++ const env = { OV_WIKI_INTERNAL_URL: wiki.url, OV_BLOG_INTERNAL_URL: blog.url, OV_NEWS_INTERNAL_URL: news.url, OV_TRADE_INTERNAL_URL: trade.url };++ const key = 'test-signing-key';++++ const requireAuth = (req, res, next) => {++ const raw = req.headers['x-test-user'];++ if (!raw) return res.status(401).json({ error: 'Authentication required' });++ const u = JSON.parse(raw);++ req.user = { id: u.id, role: u.role, subject_id: `usr_${String(u.id).padStart(26, '0')}` };++ req.token = `token-${u.id}`;++ req.tokenClaims = { staff_caps: u.caps || [] };++ next();++ };++ const app = express();++ app.use(express.urlencoded({ extended: false }));++ app.use('/admin/review', createReviewQueueRouter({ requireAuth, config: { baseUrl: 'https://openvibe.network' }, privateKey: key, env }));++ const server = await new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });++ const base = `http://127.0.0.1:${server.address().port}`;++ const auth = (extra = {}) => ({ 'x-test-user': JSON.stringify({ id: 7, role: 'admin', caps: ADMIN_CAPS, ...extra }) });++ const page = async (headers = auth()) => {++ const r = await fetch(`${base}/admin/review`, { headers, redirect: 'manual' });++ return { status: r.status, text: await r.text() };++ };++ const post = async (form, headers = auth()) => {++ const r = await fetch(`${base}/admin/review/action`, { method: 'POST', headers: { ...headers, 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(form).toString(), redirect: 'manual' });++ return { status: r.status, location: r.headers.get('location') || '', text: await r.text() };++ };++ const csrfOf = (text) => (text.match(/name="csrf" value="([^"]+)"/) || [])[1];++++ try {++ let r = await page();++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));++ assert.ok(r.text.includes('OpenVibe.Wiki') && r.text.includes('OpenVibe.Blog') && r.text.includes('OpenVibe.News') && r.text.includes('OpenVibe.Trade'));++ assert.ok(r.text.includes('More (1)'), 'the 7th Blog row is folded behind More (1)');++ assert.ok(r.text.includes('Title clu_a') && r.text.includes('AI brief'));++ assert.ok(r.text.indexOf('More (1)') < r.text.indexOf('Title pst_g'), 'the 7th row is inside the fold');++ assert.strictEqual(r.text.split('Title pst_g').length, 2, 'the folded row appears once');++ assert.ok(r.text.indexOf('Title ins_a') < r.text.indexOf('Title clu_a'), 'oldest first across sites');++ assert.ok(wiki.seen.some((x) => x.auth === 'Bearer token-7'), 'the person\'s token is forwarded');++++ r = await page(auth({ caps: [] }));++ assert.strictEqual(r.status, 403, 'a non-editorial staff token gets 403');++ assert.ok(r.text.includes('Staff only'));++++ const csrf = csrfOf((await page()).text);++ assert.ok(csrf && csrfOk(csrf, { id: 7 }, key), 'the form token verifies');++ assert.ok(!csrfOk(csrf, { id: 8 }, key), 'and binds the person');++++ r = await post({ csrf, site: 'wiki', kind: 'wiki_page', id: 'pg_a', revision: '2', decision: 'approved' });++ assert.strictEqual(r.status, 303);++ assert.match(r.location, /saved=Approved/);++ let sent = wiki.seen.filter((x) => x.method === 'POST');++ assert.deepStrictEqual(sent.map((x) => x.url), ['/api/v1/pages/pg_a/revisions/2/review']);++ assert.deepStrictEqual(sent[0].body, { decision: 'approved', note: '', revision: 2 });++++ wiki.seen.length = 0; blog.seen.length = 0;++ r = await post({ csrf, site: 'blog', kind: 'blog_draft', id: 'pst_a', revision: '2', decision: 'approved' });++ assert.strictEqual(r.status, 303);++ sent = blog.seen.filter((x) => x.method === 'POST');++ assert.deepStrictEqual(sent.map((x) => x.url), ['/api/v1/posts/pst_a/reviews', '/api/v1/posts/pst_a/publish'], 'review, then publish the draft');++++ r = await post({ csrf, site: 'news', kind: 'ai_brief', id: 'clu_a', revision: '', decision: 'approved' });++ assert.strictEqual(r.status, 303);++ sent = news.seen.filter((x) => x.method === 'POST');++ assert.deepStrictEqual(sent.map((x) => x.url), ['/api/v1/briefs/clu_a/reviews']);++ assert.deepStrictEqual(sent[0].body, { decision: 'approved', note: '', kind: 'summary', tone: 'neutral' });++++ r = await post({ csrf, site: 'trade', kind: 'company_page', id: 'ins_a', revision: '2', ref: 'ACME', decision: 'rejected' });++ assert.strictEqual(r.status, 303);++ sent = trade.seen.filter((x) => x.method === 'POST');++ assert.deepStrictEqual(sent.map((x) => x.url), ['/api/v1/instruments/ACME/context/revisions/2/review']);++ assert.deepStrictEqual(sent[0].body, { decision: 'rejected', note: '', revision: 2 });++++ r = await post({ csrf: 'bad', site: 'wiki', kind: 'wiki_page', id: 'pg_a', revision: '2', decision: 'approved' });++ assert.strictEqual(r.status, 303);++ assert.match(r.location, /error=/);++++ // A service that does not answer is a visible section, not a blank page.++ await new Promise((resolve) => wiki.srv.close(resolve));++ r = await page();++ assert.strictEqual(r.status, 200);++ assert.ok(r.text.includes('OpenVibe.Wiki did not answer'), 'a down service is named in the page');++++ const got = await collect({ token: 't', fetchImpl: async () => ({ ok: false, status: 502 }), timeoutMs: 100, env });++ assert.deepStrictEqual(got.items, []);++ assert.strictEqual(got.errors.length, 4);++ assert.strictEqual(csrfToken({ id: 7 }, key).split('.').length, 3);++ } finally {++ await new Promise((resolve) => server.close(resolve));++ for (const u of [wiki, blog, news, trade]) { try { await new Promise((resolve) => u.srv.close(resolve)); } catch { /* already closed */ } }++ }++ console.log('review-queue.test.js: ok');++})().catch((e) => { console.error(e); process.exit(1); });@@@ -125,6 +125,7 @@ const REVIEWED = {'server/frame/service.js fetch': [3, 'Live and AI internal URLs from config (navbar ranking copy); https://<open site host>/privacy, hosts from the site registry only, no redirects, 5 s (legal links)'],'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/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'],
@@ -91,6 +91,11 @@ OV_TOOLS_INTERNAL_URL=http://127.0.0.1:4001
@@ -91,6 +91,11 @@ OV_TOOLS_INTERNAL_URL=http://127.0.0.1:4001
OV_GAMES_INTERNAL_URL=http://127.0.0.1:8000
OV_GAMES_INTERNAL_URL=http://127.0.0.1:8000
OV_MEDIA_INTERNAL_URL=http://127.0.0.1:4100
OV_MEDIA_INTERNAL_URL=http://127.0.0.1:4100
OV_CHAT_INTERNAL_URL=http://127.0.0.1:4400
OV_CHAT_INTERNAL_URL=http://127.0.0.1:4400
+# The publication reviewer queue (/admin/review): each product's staff review-queue endpoint.
+OV_WIKI_INTERNAL_URL=http://127.0.0.1:4800
+OV_BLOG_INTERNAL_URL=http://127.0.0.1:4810
+OV_NEWS_INTERNAL_URL=http://127.0.0.1:4820
+OV_TRADE_INTERNAL_URL=http://127.0.0.1:4860
# Optional: SSH admin-tab context (non-secret display values)
# Optional: SSH admin-tab context (non-secret display values)
#SSH_SERVER_HOST=
#SSH_SERVER_HOST=
@@ -20,6 +20,7 @@ Identity and account service for the OpenVibe network. Manages user accounts, OA
@@ -20,6 +20,7 @@ Identity and account service for the OpenVibe network. Manages user accounts, OA
- **Theme Catalog** — Shared theme system with ~30 built-in themes and community submissions. Theme preferences sync across all services.
- **Theme Catalog** — Shared theme system with ~30 built-in themes and community submissions. Theme preferences sync across all services.
- **Per-site theme overrides** — one theme follows a person to every site, and an override is their exception for one service ("Games in Neon Tokyo", "Live in Daylight"). `GET /api/themes/me/active?service=<id>` answers that service's theme (`override: true`, with the `service` named) or the person's global theme (`override: false`); a missing or unknown service answers exactly as before. The person owns their own list at `GET /api/themes/me/overrides`, `PUT /api/themes/me/overrides/:service` and `DELETE /api/themes/me/overrides/:service` (the theme and the custom colours are validated as `PUT /api/themes/me` does), at most 50 per person, and every change is kept in the theme history with the service recorded.
- **Per-site theme overrides** — one theme follows a person to every site, and an override is their exception for one service ("Games in Neon Tokyo", "Live in Daylight"). `GET /api/themes/me/active?service=<id>` answers that service's theme (`override: true`, with the `service` named) or the person's global theme (`override: false`); a missing or unknown service answers exactly as before. The person owns their own list at `GET /api/themes/me/overrides`, `PUT /api/themes/me/overrides/:service` and `DELETE /api/themes/me/overrides/:service` (the theme and the custom colours are validated as `PUT /api/themes/me` does), at most 50 per person, and every change is kept in the theme history with the service recorded.
- **Admin Panel** — email configuration, user management (role changes, bans), broadcast notifications, system health dashboard, and audit log.
- **Admin Panel** — email configuration, user management (role changes, bans), broadcast notifications, system health dashboard, and audit log.
+- **Reviewer queue** (`/admin/review`) — one staff-only page for every Wiki page, Blog draft, AI brief and Trade company page waiting for a person, grouped by site with the publishing gate's noindex reason in words. Approve (indexable) or keep hidden calls the owning service's own review route and writes its own review log.
- **Internal API** — Server-to-server endpoints for token verification, user lookup, notification push, account linking, and audit logging. Every route takes a capability-scoped service token; the deployment-wide key they used to accept was retired on 2026-09-29 (plan T2, [docs/retirement.md](docs/retirement.md)) and opens nothing.
- **Internal API** — Server-to-server endpoints for token verification, user lookup, notification push, account linking, and audit logging. Every route takes a capability-scoped service token; the deployment-wide key they used to accept was retired on 2026-09-29 (plan T2, [docs/retirement.md](docs/retirement.md)) and opens nothing.
- **Developer projects (foundation)** — projects, members, apps (OAuth clients with `app_` subjects), hashed client secrets with rotation and revocation, capability grants within a staff-set allowance (sandbox apps also get a default sandbox allowance of public Media, Events-app and Tools-job capabilities, so a new project works without staff), recorded quotas, per-project usage from the services' hourly rollups (`GET /api/v1/projects/:project/usage`) and an append-only audit at `/api/v1/projects`. Its events can be relayed to OpenVibe.Events through an outbox when `OV_EVENTS_INTERNAL_URL` is set. Bearer user tokens only; the console over this API is OpenVibe.Services at openvibe.services (it moved there from OpenVibe.Codes on 2026-10-08). See [docs/developer-projects.md](docs/developer-projects.md).
- **Developer projects (foundation)** — projects, members, apps (OAuth clients with `app_` subjects), hashed client secrets with rotation and revocation, capability grants within a staff-set allowance (sandbox apps also get a default sandbox allowance of public Media, Events-app and Tools-job capabilities, so a new project works without staff), recorded quotas, per-project usage from the services' hourly rollups (`GET /api/v1/projects/:project/usage`) and an append-only audit at `/api/v1/projects`. Its events can be relayed to OpenVibe.Events through an outbox when `OV_EVENTS_INTERNAL_URL` is set. Bearer user tokens only; the console over this API is OpenVibe.Services at openvibe.services (it moved there from OpenVibe.Codes on 2026-10-08). See [docs/developer-projects.md](docs/developer-projects.md).
- **Public discovery** — `/.well-known/openvibe`, `/api/v1/registry/*` and `/contracts/*.json` (services, capabilities, contract schemas) answer any origin with `Access-Control-Allow-Origin: *`, preflight included, so a browser app can discover the platform directly. Every other route keeps the first-party CORS allow-list (`server/public-cors.js`).
- **Public discovery** — `/.well-known/openvibe`, `/api/v1/registry/*` and `/contracts/*.json` (services, capabilities, contract schemas) answer any origin with `Access-Control-Allow-Origin: *`, preflight included, so a browser app can discover the platform directly. Every other route keeps the first-party CORS allow-list (`server/public-cors.js`).
@@ -48,6 +48,7 @@
@@ -48,6 +48,7 @@
<div class="section-tabs-wrap">
<div class="section-tabs-wrap">
<div class="section-tabs" id="main-tabs">
<div class="section-tabs" id="main-tabs">
<button class="admin-tab-btn active" onclick="showTab('dashboard',this)"><i class="fa-solid fa-chart-line"></i> Dashboard</button>
<button class="admin-tab-btn active" onclick="showTab('dashboard',this)"><i class="fa-solid fa-chart-line"></i> Dashboard</button>
+ <button class="admin-tab-btn" onclick="location.href='/admin/review'"><i class="fa-solid fa-list-check"></i> Review queue</button>
<button class="admin-tab-btn" onclick="showTab('users',this)"><i class="fa-solid fa-users"></i> Users</button>
<button class="admin-tab-btn" onclick="showTab('users',this)"><i class="fa-solid fa-users"></i> Users</button>
<button class="admin-tab-btn" onclick="showTab('streams',this)"><i class="fa-solid fa-broadcast-tower"></i> Streams</button>
<button class="admin-tab-btn" onclick="showTab('streams',this)"><i class="fa-solid fa-broadcast-tower"></i> Streams</button>
<button class="admin-tab-btn" onclick="showTab('lineage',this)"><i class="fa-solid fa-diagram-project"></i> Lineage</button>
<button class="admin-tab-btn" onclick="showTab('lineage',this)"><i class="fa-solid fa-diagram-project"></i> Lineage</button>
new file mode 100644
new file mode 100644
@@ -0,0 +1,308 @@
@@ -0,0 +1,308 @@
+'use strict';
+/**
+ * The publication reviewer queue (plan 2026-10-22 §"Publications", lines 947-952): one page for
+ * everything OpenVibe.Wiki, Blog, News and Trade hold for a person's review, oldest first.
+ *
+ * Why here: OpenVibe.Publishing is code, never a runtime (its README: no port, domain, database or
+ * API of its own), so the queue is hosted by the service that already gives staff one console for
+ * every product — Network's admin — and uses the products' own gate reasons and review routes.
+ *
+ * GET /admin/review the queue, grouped by site, noindex, staff only
+ * POST /admin/review/action approve (indexable) or keep hidden, through the owning service's own
+ * review route. The product checks the person's role again and writes
+ * its own append-only review log; Network never invents a decision.
+ *
+ * Each service exposes GET /api/v1/review-queue to a staff/editor user token (its own check), and
+ * the existing review routes do the work. The token forwarded is the signed-in person's, renewed by
+ * Network's requireAuth, so a role granted a moment ago counts upstream.
+ */
+const crypto = require('crypto');
+const express = require('express');
+const contracts = require('openvibe-contracts');
+const kit = require('openvibe-shared/kit');
+const shell = require('openvibe-shared/shell');
+
+const SITES = [
+ { id: 'wiki', name: 'OpenVibe.Wiki', env: 'OV_WIKI_INTERNAL_URL', fallback: 'http://127.0.0.1:4800' },
+ { id: 'blog', name: 'OpenVibe.Blog', env: 'OV_BLOG_INTERNAL_URL', fallback: 'http://127.0.0.1:4810' },
+ { id: 'news', name: 'OpenVibe.News', env: 'OV_NEWS_INTERNAL_URL', fallback: 'http://127.0.0.1:4820' },
+ { id: 'trade', name: 'OpenVibe.Trade', env: 'OV_TRADE_INTERNAL_URL', fallback: 'http://127.0.0.1:4860' },
+];
+const SITE_BY_ID = new Map(SITES.map((s) => [s.id, s]));
+const KIND_LABEL = Object.freeze({
+ wiki_page: 'Wiki page', blog_draft: 'Blog draft', blog_post: 'Blog post',
+ ai_brief: 'AI brief', news_story: 'News story', company_page: 'Company page',
+});
+const KINDS = new Set(Object.keys(KIND_LABEL));
+const DECISIONS = new Set(['approved', 'rejected']);
+const FIRST = 6; // per site before the rest fold behind "More (N)"
+const MAX_ITEMS = 2000; // a service that answers too much cannot exhaust the page
+
+const esc = kit.esc;
+const ageText = (iso, now) => {
+ const ms = Date.parse(iso);
+ if (!Number.isFinite(ms)) return '';
+ const mins = Math.max(0, Math.floor((now - ms) / 60000));
+ if (mins < 60) return `${mins || 1} min`;
+ const hours = Math.floor(mins / 60);
+ if (hours < 48) return `${hours} h`;
+ const days = Math.floor(hours / 24);
+ if (days < 60) return `${days} day${days === 1 ? '' : 's'}`;
+ const months = Math.floor(days / 30);
+ return `${months} month${months === 1 ? '' : 's'}`;
+};
+
+/** A URL kept only when it is an https URL or a same-site path. */
+const safeUrl = (u) => {
+ const s = String(u == null ? '' : u).trim();
+ if (/^https:\/\//i.test(s)) return s;
+ if (/^\/[^/]/.test(s)) return s;
+ return '#';
+};
+
+function serviceBase(site, env = process.env) {
+ return String(env[site.env] || site.fallback).replace(/\/+$/, '');
+}
+
+/** One item from a service, defensively shaped: a bad row is skipped, never rendered as trusted. */
+function itemOf(raw, site, now) {
+ if (!raw || typeof raw !== 'object') return null;
+ if (raw.site !== site.id || !KINDS.has(raw.kind)) return null;
+ if (typeof raw.id !== 'string' || !raw.id || raw.id.length > 200) return null;
+ if (typeof raw.title !== 'string' || !raw.title) return null;
+ const createdAt = new Date(raw.createdAt);
+ if (!Number.isFinite(createdAt.getTime())) return null;
+ const revision = raw.revision == null ? null : Number(raw.revision);
+ if (revision != null && !Number.isInteger(revision)) return null;
+ const reasons = Array.isArray(raw.reasons) ? raw.reasons.filter((r) => r && typeof r.text === 'string').slice(0, 6).map((r) => ({ code: String(r.code || ''), text: r.text.slice(0, 300) })) : [];
+ return {
+ site: site.id, kind: raw.kind, id: raw.id, title: raw.title.slice(0, 300),
+ revision, state: typeof raw.state === 'string' ? raw.state : null, ref: typeof raw.ref === 'string' ? raw.ref : null,
+ url: safeUrl(raw.url), editUrl: raw.editUrl == null ? null : safeUrl(raw.editUrl),
+ createdAt: createdAt.toISOString(), age: ageText(createdAt.toISOString(), now),
+ reasons: reasons.length ? reasons : [{ code: 'noindex_requested', text: 'Waiting for a person\'s review.' }],
+ };
+}
+
+/** Fetch every site's queue in parallel; a down service becomes a visible section error, not a blank page. */
+async function collect({ token, fetchImpl = globalThis.fetch, now = Date.now(), timeoutMs = 5000, env = process.env } = {}) {
+ const errors = [];
+ const items = [];
+ await Promise.all(SITES.map(async (site) => {
+ const base = serviceBase(site, env);
+ try {
+ const res = await fetchImpl(`${base}/api/v1/review-queue`, {
+ headers: { Accept: 'application/json', Authorization: `Bearer ${token}` },
+ redirect: 'error', signal: AbortSignal.timeout(timeoutMs),
+ });
+ if (!res.ok) { errors.push({ site: site.id, name: site.name, status: res.status }); return; }
+ const data = await res.json();
+ const rows = data && Array.isArray(data.items) ? data.items.slice(0, MAX_ITEMS) : [];
+ for (const row of rows) { const item = itemOf(row, site, now); if (item) items.push(item); }
+ } catch {
+ errors.push({ site: site.id, name: site.name, status: 0 });
+ }
+ }));
+ items.sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt) || (a.site < b.site ? -1 : a.site > b.site ? 1 : 0));
+ return { items, errors, counts: Object.fromEntries(SITES.map((s) => [s.id, items.filter((i) => i.site === s.id).length])) };
+}
+
+function kindOf(kind) { return KIND_LABEL[kind] || kind; }
+
+function actionForms(item, csrf) {
+ const base = { csrf, site: item.site, kind: item.kind, id: item.id, revision: item.revision == null ? '' : String(item.revision), ref: item.ref || '', state: item.state || '' };
+ const approve = kit.action({ action: '/admin/review/action', hidden: { ...base, decision: 'approved' }, label: 'Approve (indexable)', icon: 'check', kind: 'primary', small: true });
+ const keep = kit.action({ action: '/admin/review/action', hidden: { ...base, decision: 'rejected' }, label: 'Keep hidden', icon: 'ban', kind: 'quiet', small: true });
+ return `<span class="rq-actions">${approve}${keep}</span>`;
+}
+
+function itemRow(item, csrf) {
+ const href = item.editUrl || item.url;
+ const title = `<a href="${esc(safeUrl(href))}">${esc(item.title)}</a>`;
+ const why = item.reasons.map((r) => r.text).join(' · ');
+ return kit.row({
+ titleHtml: title,
+ badge: kit.badge(kindOf(item.kind)),
+ meta: item.age ? `${item.age} waiting` : '',
+ metaIso: item.createdAt,
+ sub: why,
+ trail: actionForms(item, csrf),
+ });
+}
+
+function siteSection(site, items, csrf) {
+ const list = items.length ? kit.list(items.slice(0, FIRST).map((i) => itemRow(i, csrf))) : kit.empty({ title: 'Nothing is waiting', text: `${site.name} has no page waiting for a person's review.` });
+ const rest = items.slice(FIRST);
+ const more = rest.length ? kit.fold({
+ id: `rq-${site.id}-more`,
+ title: `More (${rest.length})`,
+ meta: `${items.length} in all`,
+ body: kit.list(rest.map((i) => itemRow(i, csrf))),
+ }) : '';
+ return kit.section({ id: `rq-${site.id}`, title: site.name, count: items.length, note: items.length ? 'Oldest first' : '', body: list + more });
+}
+
+function renderPage({ items, errors, counts, csrf, config, now = Date.now(), saved = null }) {
+ const total = items.length;
+ // Stacked sections, oldest waiting first; a site with nothing waiting sits at the end.
+ const failed = new Set(errors.map((e) => e.site));
+ const grouped = SITES.filter((site) => !failed.has(site.id)).map((site) => ({ site, items: items.filter((i) => i.site === site.id) }))
+ .sort((a, b) => (a.items[0] ? Date.parse(a.items[0].createdAt) : Infinity) - (b.items[0] ? Date.parse(b.items[0].createdAt) : Infinity));
+ const stats = kit.stats([
+ [total, 'waiting for review'],
+ ...SITES.map((s) => [counts[s.id], s.name.replace('OpenVibe.', '')]),
+ ], { label: 'Review queue numbers' });
+ const notices = [
+ saved && saved.error ? kit.notice({ kind: 'error', text: saved.error }) : '',
+ saved && saved.ok ? kit.notice({ kind: 'ok', text: saved.ok }) : '',
+ ...errors.map((e) => kit.notice({ kind: 'warn', text: `${e.name} did not answer${e.status ? ` (${e.status})` : ''}; its section is missing.` })),
+ ].join('');
+ const empty = !total && !errors.length
+ ? kit.empty({ title: 'Nothing is waiting', text: 'Every publication has a person\'s decision for its AI-written text.', icon: 'check' })
+ : '';
+ const body = `<main class="rq">
+${kit.head({ title: 'Review queue', sub: 'Everything waiting for a person, oldest first.', back: { href: '/admin', label: 'Admin' } })}
+${notices}
+${stats}
+${empty || grouped.map(({ site, items: rows }) => siteSection(site, rows, csrf)).join('\n')}
+</main>`;
+ return shell.page({
+ name: 'network', service: 'network',
+ title: 'Review queue', siteName: 'OpenVibe.Network',
+ description: 'Every Wiki page, Blog draft, AI brief and company page waiting for a person\'s review.',
+ canonical: `${String(config.baseUrl || 'https://openvibe.network').replace(/\/+$/, '')}/admin/review`,
+ robots: 'noindex, nofollow',
+ head: `<link rel="stylesheet" href="/shared/kit.css">
+<script src="/shared/kit-web.js" defer></script>
+<style>
+.rq{max-width:900px;margin:0 auto;padding:16px}
+.rq .ovk-head{margin-bottom:12px}
+.rq-actions{display:flex;gap:8px;flex-wrap:wrap;justify-content:flex-end}
+.rq-actions form{margin:0}
+.rq .ovk-row-trail{max-width:100%}
+@media (max-width:599px){
+.rq .ovk-stats{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));margin:12px 0;padding:0;overflow:visible;gap:8px}
+.rq .ovk-row--static{flex-direction:column;align-items:stretch;gap:6px}
+.rq .ovk-row-main{width:100%}
+.rq .ovk-row-trail{width:100%}
+.rq .ovk-row-top{flex-wrap:wrap}
+.rq .ovk-row-top strong{flex:1 1 100%;white-space:normal;overflow:visible;text-overflow:clip}
+.rq .ovk-row-top strong a{display:block;overflow-wrap:anywhere}
+.rq .ovk-row-sub>span:first-child{white-space:normal;overflow:visible}
+.rq-actions{justify-content:flex-start}
+}
+</style>`,
+ body,
+ navLinks: [{ label: 'Admin', href: '/admin' }],
+ navbar: { service: 'network' },
+ footer: { service: 'network', variant: 'compact' },
+ });
+}
+
+/** HMAC over the person, purpose and expiry; the key is Network's signing key (never a new secret). */
+function csrfToken(user, key, now = Date.now()) {
+ const exp = Math.floor(now / 1000) + 3600;
+ const body = `${user.id}.${exp}`;
+ return `${body}.${crypto.createHmac('sha256', key).update(`review-queue:${body}`).digest('base64url')}`;
+}
+function csrfOk(token, user, key, now = Date.now()) {
+ const parts = String(token || '').split('.');
+ if (parts.length !== 3 || String(parts[0]) !== String(user.id)) return false;
+ const exp = Number(parts[1]);
+ if (!Number.isInteger(exp) || exp < Math.floor(now / 1000)) return false;
+ const want = crypto.createHmac('sha256', key).update(`review-queue:${parts[0]}.${parts[1]}`).digest('base64url');
+ const a = Buffer.from(String(parts[2])), b = Buffer.from(want);
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
+}
+
+function createReviewQueueRouter({ requireAuth, config = {}, privateKey, fetchImpl = globalThis.fetch, now = Date.now, timeoutMs = 5000, env = process.env }) {
+ const router = express.Router();
+ const form = express.urlencoded({ extended: false, limit: '8kb' });
+ const editorial = (req) => Boolean(req.user && req.tokenClaims && contracts.staff.can(req.tokenClaims, 'staff.editorial.manage'));
+ const refuse = (req, res) => {
+ res.status(403).type('html').set('Cache-Control', 'no-store').send(shell.page({
+ name: 'network', service: 'network', title: 'Staff only', robots: 'noindex, nofollow',
+ canonical: `${String(config.baseUrl || 'https://openvibe.network').replace(/\/+$/, '')}/admin/review`,
+ head: '<link rel="stylesheet" href="/shared/kit.css">',
+ body: `<main class="rq"><h1>Staff only</h1><p>Only OpenVibe editorial staff review the publications.</p><p><a href="/">OpenVibe.Network</a></p></main>`,
+ navLinks: [{ label: 'Admin', href: '/admin' }],
+ }));
+ };
+ const guard = [requireAuth, (req, res, next) => (editorial(req) ? next() : refuse(req, res))];
+
+ router.get('/', guard, async (req, res, next) => {
+ try {
+ const key = (req.app.locals && req.app.locals.privateKey) || privateKey;
+ if (!key) throw new Error('the signing key is not loaded');
+ const got = await collect({ token: req.token, fetchImpl, now: now(), timeoutMs, env });
+ const saved = req.query.saved ? { ok: String(req.query.saved).slice(0, 300) } : null;
+ const failed = req.query.error ? { error: String(req.query.error).slice(0, 300) } : null;
+ res.set('Cache-Control', 'no-store').type('html').send(renderPage({
+ ...got, csrf: csrfToken(req.user, key, now()), config, now: now(), saved: saved || failed,
+ }));
+ } catch (err) {
+ next(err);
+ }
+ });
+
+ router.post('/action', ...guard, form, async (req, res, next) => {
+ try {
+ const key = (req.app.locals && req.app.locals.privateKey) || privateKey;
+ if (!key || !csrfOk(req.body && req.body.csrf, req.user, key, now())) {
+ return res.redirect(303, '/admin/review?error=' + encodeURIComponent('That form expired. Reload the queue and try again.'));
+ }
+ const b = req.body || {};
+ const site = SITE_BY_ID.get(String(b.site || ''));
+ const kind = String(b.kind || '');
+ const id = String(b.id || '');
+ const decision = String(b.decision || '');
+ const revision = Number(b.revision);
+ const ref = String(b.ref || '');
+ if (!site || !KINDS.has(kind) || !id || !DECISIONS.has(decision) || (b.revision !== '' && !Number.isInteger(revision))) {
+ return res.redirect(303, '/admin/review?error=' + encodeURIComponent('That item could not be read. Reload the queue and try again.'));
+ }
+ const base = serviceBase(site, env);
+ const body = { decision, note: '' };
+ let path;
+ if (site.id === 'wiki' && kind === 'wiki_page' && Number.isInteger(revision)) path = `/api/v1/pages/${encodeURIComponent(id)}/revisions/${revision}/review`;
+ else if (site.id === 'blog' && (kind === 'blog_draft' || kind === 'blog_post') && Number.isInteger(revision)) path = `/api/v1/posts/${encodeURIComponent(id)}/reviews`;
+ else if (site.id === 'news' && kind === 'news_story' && Number.isInteger(revision)) path = `/api/v1/stories/${encodeURIComponent(id)}/reviews`;
+ else if (site.id === 'news' && kind === 'ai_brief') path = `/api/v1/briefs/${encodeURIComponent(id)}/reviews`;
+ else if (site.id === 'trade' && kind === 'company_page' && Number.isInteger(revision) && ref) path = `/api/v1/instruments/${encodeURIComponent(ref)}/context/revisions/${revision}/review`;
+ else return res.redirect(303, '/admin/review?error=' + encodeURIComponent('That item has no action for its kind.'));
+
+ if (site.id === 'news' && kind === 'ai_brief') { body.kind = 'summary'; body.tone = 'neutral'; }
+ if (site.id !== 'news' || kind !== 'ai_brief') body.revision = revision;
+ const call = async (p, payload) => {
+ const r = await fetchImpl(`${base}${p}`, {
+ method: 'POST', redirect: 'error',
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json', Authorization: `Bearer ${req.token}` },
+ body: JSON.stringify(payload), signal: AbortSignal.timeout(timeoutMs),
+ });
+ if (!r.ok) {
+ const problem = await r.json().catch(() => null);
+ throw new Error((problem && (problem.detail || problem.title)) || `the service answered ${r.status}`);
+ }
+ return await r.json().catch(() => ({}));
+ };
+ await call(path, body);
+ // A draft is reviewed and then published: the review alone leaves it a draft (still noindex).
+ if (decision === 'approved' && site.id === 'blog' && kind === 'blog_draft') await call(`/api/v1/posts/${encodeURIComponent(id)}/publish`, { revision });
+ if (decision === 'approved' && site.id === 'news' && kind === 'news_story' && b.state !== 'published') await call(`/api/v1/stories/${encodeURIComponent(id)}/publish`, { revision });
+ const what = decision === 'approved' ? 'Approved' : 'Kept hidden';
+ res.redirect(303, '/admin/review?saved=' + encodeURIComponent(`${what}: ${site.name}.`));
+ } catch (err) {
+ next(err);
+ }
+ });
+
+ // A failed upstream write shows on the page, not as a stack trace.
+ router.use((err, req, res, next) => {
+ if (!err || res.headersSent) return next(err);
+ res.redirect(303, '/admin/review?error=' + encodeURIComponent(String(err.message || 'The action did not complete').slice(0, 300)));
+ });
+
+ return router;
+}
+
+module.exports = { SITES, KIND_LABEL, collect, itemOf, renderPage, createReviewQueueRouter, csrfToken, csrfOk, ageText };
@@ -1025,6 +1025,10 @@ app.get(['/login', '/forgot-password', '/reset-password'], (req, res) => {
@@ -1025,6 +1025,10 @@ app.get(['/login', '/forgot-password', '/reset-password'], (req, res) => {
res.sendFile(path.join(__dirname, '..', 'public', 'login.html'));
res.sendFile(path.join(__dirname, '..', 'public', 'login.html'));
});
});
+// The publication reviewer queue: a server-rendered staff page (openvibe-shared/kit) before the
+// admin SPA's catch-all. It asks each publication's own review-queue endpoint with the person's token.
+app.use('/admin/review', require('./admin/review-queue').createReviewQueueRouter({ requireAuth, config, privateKey: app.locals.privateKey }));
+
// Admin panel — serve the SPA for /admin and any client-routed sub-path
// Admin panel — serve the SPA for /admin and any client-routed sub-path
// (e.g. /admin/settings, /admin/analytics/overview). Auth is checked client-side.
// (e.g. /admin/settings, /admin/analytics/overview). Auth is checked client-side.
app.get(['/admin', '/admin/*'], (req, res) => {
app.get(['/admin', '/admin/*'], (req, res) => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,134 @@
@@ -0,0 +1,134 @@
+'use strict';
+/**
+ * The publication reviewer queue (server/admin/review-queue.js): one staff page across Wiki, Blog,
+ * News and Trade, oldest first, grouped by site with a "More (N)" fold; approve/keep-hidden call the
+ * owning service's own review route (and publish a draft after approval), with the person's token.
+ */
+const assert = require('assert');
+const http = require('http');
+const express = require('express');
+const contracts = require('openvibe-contracts');
+const { createReviewQueueRouter, collect, csrfToken, csrfOk } = require('../server/admin/review-queue');
+
+const ADMIN_CAPS = contracts.staff.capabilitiesOf({ role: 'admin' });
+
+function upstream(label, items) {
+ const seen = [];
+ const srv = http.createServer((req, res) => {
+ let body = '';
+ req.on('data', (d) => { body += d; });
+ req.on('end', () => {
+ seen.push({ method: req.method, url: req.url, auth: req.headers.authorization || null, body: body ? JSON.parse(body) : null });
+ if (req.url === '/api/v1/review-queue') {
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ items: items.map((i) => ({ ...i, site: label })) }));
+ } else {
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true }));
+ }
+ });
+ });
+ return new Promise((resolve) => srv.listen(0, '127.0.0.1', () => resolve({ srv, seen, url: `http://127.0.0.1:${srv.address().port}` })));
+}
+
+const item = (id, kind, createdAt, extra = {}) => ({
+ id, kind, title: `Title ${id}`, revision: 2, url: `/x/${id}`, createdAt,
+ reasons: [{ code: 'ai_generated_unreviewed', text: 'AI-written and not reviewed by a person.' }],
+ review: { method: 'POST', path: '/ignored' }, ...extra,
+});
+
+(async () => {
+ const wiki = await upstream('wiki', [item('pg_a', 'wiki_page', '2026-10-01T00:00:00Z'), item('pg_b', 'wiki_page', '2026-10-03T00:00:00Z')]);
+ const blog = await upstream('blog', [item('pst_a', 'blog_draft', '2026-10-02T00:00:00Z'), item('pst_b', 'blog_draft', '2026-10-04T00:00:00Z'), item('pst_c', 'blog_draft', '2026-10-05T00:00:00Z'), item('pst_d', 'blog_draft', '2026-10-06T00:00:00Z'), item('pst_e', 'blog_draft', '2026-10-07T00:00:00Z'), item('pst_f', 'blog_draft', '2026-10-08T00:00:00Z'), item('pst_g', 'blog_draft', '2026-10-09T00:00:00Z')]);
+ const news = await upstream('news', [item('clu_a', 'ai_brief', '2026-09-30T00:00:00Z')]);
+ const trade = await upstream('trade', [item('ins_a', 'company_page', '2026-09-29T00:00:00Z', { ref: 'ACME' })]);
+ const env = { OV_WIKI_INTERNAL_URL: wiki.url, OV_BLOG_INTERNAL_URL: blog.url, OV_NEWS_INTERNAL_URL: news.url, OV_TRADE_INTERNAL_URL: trade.url };
+ const key = 'test-signing-key';
+
+ const requireAuth = (req, res, next) => {
+ const raw = req.headers['x-test-user'];
+ if (!raw) return res.status(401).json({ error: 'Authentication required' });
+ const u = JSON.parse(raw);
+ req.user = { id: u.id, role: u.role, subject_id: `usr_${String(u.id).padStart(26, '0')}` };
+ req.token = `token-${u.id}`;
+ req.tokenClaims = { staff_caps: u.caps || [] };
+ next();
+ };
+ const app = express();
+ app.use(express.urlencoded({ extended: false }));
+ app.use('/admin/review', createReviewQueueRouter({ requireAuth, config: { baseUrl: 'https://openvibe.network' }, privateKey: key, env }));
+ const server = await new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const auth = (extra = {}) => ({ 'x-test-user': JSON.stringify({ id: 7, role: 'admin', caps: ADMIN_CAPS, ...extra }) });
+ const page = async (headers = auth()) => {
+ const r = await fetch(`${base}/admin/review`, { headers, redirect: 'manual' });
+ return { status: r.status, text: await r.text() };
+ };
+ const post = async (form, headers = auth()) => {
+ const r = await fetch(`${base}/admin/review/action`, { method: 'POST', headers: { ...headers, 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(form).toString(), redirect: 'manual' });
+ return { status: r.status, location: r.headers.get('location') || '', text: await r.text() };
+ };
+ const csrfOf = (text) => (text.match(/name="csrf" value="([^"]+)"/) || [])[1];
+
+ try {
+ let r = await page();
+ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
+ assert.ok(r.text.includes('OpenVibe.Wiki') && r.text.includes('OpenVibe.Blog') && r.text.includes('OpenVibe.News') && r.text.includes('OpenVibe.Trade'));
+ assert.ok(r.text.includes('More (1)'), 'the 7th Blog row is folded behind More (1)');
+ assert.ok(r.text.includes('Title clu_a') && r.text.includes('AI brief'));
+ assert.ok(r.text.indexOf('More (1)') < r.text.indexOf('Title pst_g'), 'the 7th row is inside the fold');
+ assert.strictEqual(r.text.split('Title pst_g').length, 2, 'the folded row appears once');
+ assert.ok(r.text.indexOf('Title ins_a') < r.text.indexOf('Title clu_a'), 'oldest first across sites');
+ assert.ok(wiki.seen.some((x) => x.auth === 'Bearer token-7'), 'the person\'s token is forwarded');
+
+ r = await page(auth({ caps: [] }));
+ assert.strictEqual(r.status, 403, 'a non-editorial staff token gets 403');
+ assert.ok(r.text.includes('Staff only'));
+
+ const csrf = csrfOf((await page()).text);
+ assert.ok(csrf && csrfOk(csrf, { id: 7 }, key), 'the form token verifies');
+ assert.ok(!csrfOk(csrf, { id: 8 }, key), 'and binds the person');
+
+ r = await post({ csrf, site: 'wiki', kind: 'wiki_page', id: 'pg_a', revision: '2', decision: 'approved' });
+ assert.strictEqual(r.status, 303);
+ assert.match(r.location, /saved=Approved/);
+ let sent = wiki.seen.filter((x) => x.method === 'POST');
+ assert.deepStrictEqual(sent.map((x) => x.url), ['/api/v1/pages/pg_a/revisions/2/review']);
+ assert.deepStrictEqual(sent[0].body, { decision: 'approved', note: '', revision: 2 });
+
+ wiki.seen.length = 0; blog.seen.length = 0;
+ r = await post({ csrf, site: 'blog', kind: 'blog_draft', id: 'pst_a', revision: '2', decision: 'approved' });
+ assert.strictEqual(r.status, 303);
+ sent = blog.seen.filter((x) => x.method === 'POST');
+ assert.deepStrictEqual(sent.map((x) => x.url), ['/api/v1/posts/pst_a/reviews', '/api/v1/posts/pst_a/publish'], 'review, then publish the draft');
+
+ r = await post({ csrf, site: 'news', kind: 'ai_brief', id: 'clu_a', revision: '', decision: 'approved' });
+ assert.strictEqual(r.status, 303);
+ sent = news.seen.filter((x) => x.method === 'POST');
+ assert.deepStrictEqual(sent.map((x) => x.url), ['/api/v1/briefs/clu_a/reviews']);
+ assert.deepStrictEqual(sent[0].body, { decision: 'approved', note: '', kind: 'summary', tone: 'neutral' });
+
+ r = await post({ csrf, site: 'trade', kind: 'company_page', id: 'ins_a', revision: '2', ref: 'ACME', decision: 'rejected' });
+ assert.strictEqual(r.status, 303);
+ sent = trade.seen.filter((x) => x.method === 'POST');
+ assert.deepStrictEqual(sent.map((x) => x.url), ['/api/v1/instruments/ACME/context/revisions/2/review']);
+ assert.deepStrictEqual(sent[0].body, { decision: 'rejected', note: '', revision: 2 });
+
+ r = await post({ csrf: 'bad', site: 'wiki', kind: 'wiki_page', id: 'pg_a', revision: '2', decision: 'approved' });
+ assert.strictEqual(r.status, 303);
+ assert.match(r.location, /error=/);
+
+ // A service that does not answer is a visible section, not a blank page.
+ await new Promise((resolve) => wiki.srv.close(resolve));
+ r = await page();
+ assert.strictEqual(r.status, 200);
+ assert.ok(r.text.includes('OpenVibe.Wiki did not answer'), 'a down service is named in the page');
+
+ const got = await collect({ token: 't', fetchImpl: async () => ({ ok: false, status: 502 }), timeoutMs: 100, env });
+ assert.deepStrictEqual(got.items, []);
+ assert.strictEqual(got.errors.length, 4);
+ assert.strictEqual(csrfToken({ id: 7 }, key).split('.').length, 3);
+ } finally {
+ await new Promise((resolve) => server.close(resolve));
+ for (const u of [wiki, blog, news, trade]) { try { await new Promise((resolve) => u.srv.close(resolve)); } catch { /* already closed */ } }
+ }
+ console.log('review-queue.test.js: ok');
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -125,6 +125,7 @@ const REVIEWED = {
@@ -125,6 +125,7 @@ const REVIEWED = {
'server/frame/service.js fetch': [3, 'Live and AI internal URLs from config (navbar ranking copy); https://<open site host>/privacy, hosts from the site registry only, no redirects, 5 s (legal links)'],
'server/frame/service.js fetch': [3, 'Live and AI internal URLs from config (navbar ranking copy); https://<open site host>/privacy, hosts from the site registry only, no redirects, 5 s (legal links)'],
'server/stats/open.js fetch': [1, 'open stats (/stats): fixed first-party loopback URLs only — Live (OV_LIVE_INTERNAL_URL, its public home numbers and service-token analytics totals) and Prometheus (PROMETHEUS_URL, loopback by default) with fixed queries; the request only picks 7, 30 or 90 days'],
'server/stats/open.js fetch': [1, 'open stats (/stats): fixed first-party loopback URLs only — Live (OV_LIVE_INTERNAL_URL, its public home numbers and service-token analytics totals) and Prometheus (PROMETHEUS_URL, loopback by default) with fixed queries; the request only picks 7, 30 or 90 days'],
'server/admin/service-proxies.js fetch': [1, 'the admin panel proxies: Live\'s and Chat\'s internal URLs from config, fixed path prefixes, staff only'],
'server/admin/service-proxies.js fetch': [1, 'the admin panel proxies: Live\'s and Chat\'s internal URLs from config, fixed path prefixes, staff only'],
+ 'server/admin/review-queue.js fetch': [2, 'the reviewer queue: Wiki/Blog/News/Trade internal URLs from operator config (loopback defaults), fixed /api/v1/review-queue and review paths, ids percent-encoded, the signed-in staff member\'s own token, staff only'],
'server/admin/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: 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/integrations/github.js fetch': [1, 'fixed host api.github.com (asserted below)'],
'server/integrations/github.js fetch': [1, 'fixed host api.github.com (asserted below)'],
'server/notifications/email-service.js http.request': [1, 'fixed host api.resend.com'],
'server/notifications/email-service.js http.request': [1, 'fixed host api.resend.com'],