openvibers/openvibe.network Commits

Admin reviewer queue: one page across Wiki, Blog, News and Trade (#567)

OP OpenVibers committed 7 days ago · 62c278611d · 7 files, +454 −0

* Admin reviewer queue: one page across Wiki, Blog, News and Trade

* Document the publication service URLs the reviewer queue calls

* Review the reviewer queue's fixed internal fetch sites

Changes

ddiff --git a/.env.example b/.env.example
iindex 0d1d554c..7f2f21bf 100644
---- a/.env.example
++++ b/.env.example
@@@ -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_MEDIA_INTERNAL_URL=http://127.0.0.1:4100
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)
#SSH_SERVER_HOST=
ddiff --git a/README.md b/README.md
iindex f8ea6f22..1fa951d7 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.
++- **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`).
ddiff --git a/public/admin.html b/public/admin.html
iindex 2f630a11..d43a3157 100644
---- a/public/admin.html
++++ b/public/admin.html
@@@ -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>
ddiff --git a/server/admin/review-queue.js b/server/admin/review-queue.js
nnew file mode 100644
iindex 00000000..e68ed991
---- /dev/null
++++ b/server/admin/review-queue.js
@@@ -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 };
ddiff --git a/server/index.js b/server/index.js
iindex f40fdb63..3a481742 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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) => {
ddiff --git a/test/review-queue.test.js b/test/review-queue.test.js
nnew file mode 100644
iindex 00000000..c04e9069
---- /dev/null
++++ b/test/review-queue.test.js
@@@ -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); });
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex a10c0db2..69758594 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -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'],