openvibers/openvibe.network Commits

Community themes with a review queue, import and export (WS-E task 2): a submitted or imported theme is pending and private to its author (usable by them at once, at most five waiting) until an admin approves it at /api/admin/themes (Theme review tab; a rejection needs a reason the author sees); the catalog and theme pages show approved themes only; themes export as openvibe-theme@1 files; theme and custom-colour values are allow-listed (shared token names only; colours, numbers, lengths and shadows only: no url(), var() or break-out). My account: Share a theme card

OP OpenVibers committed 26 Sept 2026 · 4f28b66aa7 · 7 files, +390 −34


Changes

ddiff --git a/package.json b/package.json
iindex aa5847d1..14a71474 100644
---- a/package.json
++++ b/package.json
@@@ -6,7 +6,7 @@
"scripts": {
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
-- "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/github-integration.test.js && node test/revocation.test.js && node test/usernames.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-manifest-fixture.test.js && node test/staff-claims.test.js && node test/profile-events.test.js && node test/grants-admin.test.js && node test/events-ops.test.js && node test/deploy-drift.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js && node test/blocks.test.js && node test/site-config.test.js && node test/perf-budget.test.js && node test/shutdown.test.js && node test/loyalty-policy.test.js",
++ "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/github-integration.test.js && node test/revocation.test.js && node test/usernames.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-manifest-fixture.test.js && node test/staff-claims.test.js && node test/profile-events.test.js && node test/grants-admin.test.js && node test/events-ops.test.js && node test/deploy-drift.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js && node test/blocks.test.js && node test/site-config.test.js && node test/perf-budget.test.js && node test/shutdown.test.js && node test/loyalty-policy.test.js && node test/themes-review.test.js",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
},
ddiff --git a/public/admin.html b/public/admin.html
iindex 72186a00..2ea674df 100644
---- a/public/admin.html
++++ b/public/admin.html
@@@ -214,6 +214,7 @@ body{min-height:100%;color:var(--text);font-family:'Inter',-apple-system,BlinkMa
<button class="admin-tab-btn" onclick="showTab('audit',this)"><i class="fa-solid fa-clipboard-list"></i> Audit</button>
<button class="admin-tab-btn" onclick="showTab('modlog',this)"><i class="fa-solid fa-gavel"></i> Moderation log</button>
<button class="admin-tab-btn owner-only" onclick="showTab('grants',this)"><i class="fa-solid fa-key"></i> Service grants</button>
++ <button class="admin-tab-btn" onclick="showTab('themes',this)"><i class="fa-solid fa-palette"></i> Theme review</button>
<button class="admin-tab-btn owner-only" onclick="showTab('eventsops',this)"><i class="fa-solid fa-route"></i> Event deliveries</button>
<button class="admin-tab-btn" onclick="showTab('analytics',this)"><i class="fa-solid fa-chart-pie"></i> Analytics</button>
<button class="admin-tab-btn owner-only" onclick="showTab('domains',this)"><i class="fa-solid fa-globe"></i> Domains</button>
@@@ -406,6 +407,12 @@ body{min-height:100%;color:var(--text);font-family:'Inter',-apple-system,BlinkMa
<div style="display:flex;justify-content:center;margin-top:12px"><button id="modlog-more" class="btn" hidden onclick="loadModLog(false)">Load more</button></div>
</div>
++ <!-- Community theme review (WS-E task 2): submissions stay private until approved here -->
++ <div class="section" id="sec-themes">
++ <p class="muted" style="margin:0 0 10px">Community themes waiting for review. Approving one puts it in the public catalog; rejecting needs a reason, which the author sees. Values are already limited to the shared colour tokens.</p>
++ <div id="themes-review-content"><div class="loading">Loading...</div></div>
++ </div>
++
<!-- Service grants (WS-D task 3): what each service principal may do, owner-only and audited -->
<div class="section" id="sec-grants">
<p class="muted" style="margin:0 0 10px">What each service may ask other services for. Tokens carry a change within 5 minutes. Every change needs a reason, is audited below and is announced as <code>network.principal_grant.changed</code>. Defaults come from the code.</p>
@@@ -856,6 +863,7 @@ const tabLoaders = {
audit: loadAudit,
modlog: () => loadModLog(true),
grants: () => loadGrants(),
++ themes: () => loadThemeReview(),
eventsops: () => loadDeliveries(),
analytics: loadAnalytics,
deploy: loadDeployOverview,
@@@ -3650,6 +3658,36 @@ async function replayDeliveries(sub, ids) {
try { const r = await api('/api/admin/events/replay', { method: 'POST', body: { subscription_id: sub, event_ids: eventIds } }); alert(`Queued ${r.queued}`); loadDeliveries(); }
catch (err) { alert(err.message || 'Replay failed'); }
}
++async function loadThemeReview() {
++ const c = document.getElementById('themes-review-content');
++ let list = [];
++ try { list = (await api('/api/admin/themes?status=pending')).themes || []; } catch (err) { c.textContent = 'Could not load: ' + err.message; return; }
++ c.replaceChildren();
++ if (!list.length) { const p = document.createElement('p'); p.className = 'muted'; p.textContent = 'Nothing waiting for review.'; c.appendChild(p); return; }
++ for (const t of list) {
++ const card = document.createElement('div');
++ card.style.cssText = 'border:1px solid var(--border);border-radius:10px;padding:10px 12px;margin-bottom:8px;display:grid;gap:8px';
++ const head = document.createElement('div');
++ const n = document.createElement('strong'); n.textContent = t.name + ' ';
++ const meta = document.createElement('span'); meta.className = 'muted'; meta.textContent = `/${t.slug} · ${t.mode} · by ${t.author || 'unknown'}`;
++ head.append(n, meta);
++ const swatches = document.createElement('div'); swatches.style.cssText = 'display:flex;gap:4px;flex-wrap:wrap';
++ for (const [k, v] of Object.entries(t.variables || {}).filter(([, v]) => /^#|^rgb|^hsl/i.test(v)).slice(0, 14)) {
++ const sw = document.createElement('span'); sw.title = `${k}: ${v}`; sw.style.cssText = 'width:22px;height:22px;border-radius:6px;border:1px solid var(--border)'; sw.style.background = v; swatches.appendChild(sw);
++ }
++ const note = document.createElement('input'); note.placeholder = 'Reason (needed to reject)'; note.maxLength = 300; note.style.minWidth = '240px';
++ const act = (decision) => async () => {
++ try { await api(`/api/admin/themes/${encodeURIComponent(t.id)}/review`, { method: 'POST', body: JSON.stringify({ decision, note: note.value }) }); loadThemeReview(); }
++ catch (err) { alert(err.message); }
++ };
++ const ok = document.createElement('button'); ok.className = 'btn'; ok.textContent = 'Approve'; ok.onclick = act('approve');
++ const no = document.createElement('button'); no.className = 'btn'; no.textContent = 'Reject'; no.onclick = act('reject');
++ const row = document.createElement('div'); row.style.cssText = 'display:flex;gap:8px;flex-wrap:wrap;align-items:center'; row.append(note, ok, no);
++ card.append(head, swatches, row);
++ c.appendChild(card);
++ }
++}
++
let _grants = [];
async function loadGrants() {
const c = document.getElementById('grants-content');
ddiff --git a/public/my.html b/public/my.html
iindex 142fe59d..67ceedad 100644
---- a/public/my.html
++++ b/public/my.html
@@@ -376,6 +376,21 @@ a.linked-item:hover{background:var(--bg-hover,var(--bg-card));transform:translat
</div>
</div>
</div>
++
++ <div class="card" style="margin-top:16px">
++ <h3 style="margin:0"><span class="icon"><i class="fa-solid fa-share-nodes"></i></span> Share a theme</h3>
++ <p style="margin:6px 0 12px;color:var(--text-secondary);font-size:0.9rem">Export your active theme as a file, import one, or submit your colours as a community theme. Submissions are reviewed before anyone else sees them; you can use yours right away.</p>
++ <div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center">
++ <button class="btn btn-outline" onclick="exportActiveTheme(this)"><i class="fa-solid fa-file-export"></i> Export active theme</button>
++ <label class="btn btn-outline" style="cursor:pointer"><i class="fa-solid fa-file-import"></i> Import a theme file<input type="file" accept=".json,application/json" onchange="importThemeFile(this)" style="display:none"></label>
++ </div>
++ <form onsubmit="submitMyTheme(event)" style="display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end;margin-top:14px">
++ <label style="display:grid;gap:4px;font-size:0.85rem">Name<input id="theme-submit-name" required minlength="2" maxlength="60" style="min-width:180px"></label>
++ <label style="display:grid;gap:4px;font-size:0.85rem">Address (lowercase, hyphens)<input id="theme-submit-slug" required pattern="[a-z0-9-]{2,48}" style="min-width:180px"></label>
++ <button class="btn btn-primary" type="submit"><i class="fa-solid fa-paper-plane"></i> Submit my colours</button>
++ </form>
++ <div id="theme-submissions" style="margin-top:14px"></div>
++ </div>
</div>
<!-- Accounts Section -->
@@@ -1023,6 +1038,72 @@ async function saveCustomColors(btn) {
} catch (err) { alert('Error: ' + err.message); }
finally { if (btn) btn.disabled = false; }
}
++// ── Share a theme (export, import, submit for review; WS-E task 2) ──
++async function exportActiveTheme(btn) {
++ if (btn) btn.disabled = true;
++ try {
++ const res = await fetch('/api/themes/' + encodeURIComponent(activeThemeId) + '/export', { credentials: 'include' });
++ if (!res.ok) throw new Error('This theme cannot be exported');
++ const file = await res.json();
++ if (activeCustomVars && Object.keys(activeCustomVars).length) file.variables = Object.assign({}, file.variables, activeCustomVars);
++ const a = document.createElement('a');
++ a.href = URL.createObjectURL(new Blob([JSON.stringify(file, null, 2)], { type: 'application/json' }));
++ a.download = (file.slug || 'theme') + '.openvibe-theme.json';
++ document.body.appendChild(a); a.click(); a.remove();
++ setTimeout(() => URL.revokeObjectURL(a.href), 1000);
++ } catch (err) { alert('Error: ' + err.message); }
++ finally { if (btn) btn.disabled = false; }
++}
++async function importThemeFile(input) {
++ const f = input.files && input.files[0];
++ input.value = '';
++ if (!f) return;
++ try {
++ const file = JSON.parse(await f.text());
++ const out = await apiFetch('/api/themes/import', { method: 'POST', body: JSON.stringify(file) });
++ alert('Imported "' + out.theme.name + '". It is waiting for review; you can use it now from your submissions.');
++ loadThemeSubmissions();
++ } catch (err) { alert('Import failed: ' + err.message); }
++}
++async function submitMyTheme(ev) {
++ ev.preventDefault();
++ const name = document.getElementById('theme-submit-name').value.trim();
++ const slug = document.getElementById('theme-submit-slug').value.trim();
++ const variables = Object.assign({}, activeThemeVars, activeCustomVars);
++ try {
++ await apiFetch('/api/themes', { method: 'POST', body: JSON.stringify({ name, slug, variables, mode: document.documentElement.dataset.mode === 'light' ? 'light' : 'dark' }) });
++ ev.target.reset();
++ loadThemeSubmissions();
++ } catch (err) { alert('Not submitted: ' + err.message); }
++}
++async function loadThemeSubmissions() {
++ const box = document.getElementById('theme-submissions');
++ if (!box) return;
++ let list = [];
++ try { list = (await apiFetch('/api/themes/me/submissions')).themes || []; } catch { return; }
++ box.replaceChildren();
++ if (!list.length) return;
++ const h = document.createElement('div');
++ h.textContent = 'Your submissions';
++ h.style.cssText = 'font-weight:600;margin-bottom:6px';
++ box.appendChild(h);
++ const labels = { pending: 'Waiting for review', approved: 'Approved', rejected: 'Not accepted' };
++ for (const t of list) {
++ const row = document.createElement('div');
++ row.style.cssText = 'display:flex;gap:10px;align-items:center;flex-wrap:wrap;padding:6px 0;border-top:1px solid var(--border)';
++ const n = document.createElement('strong'); n.textContent = t.name;
++ const st = document.createElement('span'); st.textContent = labels[t.review_status] || t.review_status;
++ st.style.cssText = 'font-size:0.8rem;color:var(--text-secondary)';
++ row.append(n, st);
++ if (t.review_note) { const note = document.createElement('span'); note.textContent = '— ' + t.review_note; note.style.cssText = 'font-size:0.8rem;color:var(--text-muted)'; row.appendChild(note); }
++ if (t.review_status !== 'rejected') {
++ const use = document.createElement('button'); use.type = 'button'; use.className = 'btn btn-outline'; use.textContent = 'Use';
++ use.onclick = async () => { try { await apiFetch('/api/themes/me', { method: 'PUT', body: JSON.stringify({ theme_id: t.id }) }); location.reload(); } catch (err) { alert('Error: ' + err.message); } };
++ row.appendChild(use);
++ }
++ box.appendChild(row);
++ }
++}
async function resetCustomColors(btn) {
if (btn) btn.disabled = true;
try {
@@@ -1122,7 +1203,7 @@ async function loadUser() {
currentUser = data.user;
renderHeader(data.user);
renderProfile(data.user);
-- loadThemes();
++ loadThemes(); loadThemeSubmissions();
loadAccounts();
loadNotifPrefs();
loadLinked();
ddiff --git a/server/index.js b/server/index.js
iindex 2c4622f8..3f874c67 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -572,6 +572,8 @@ app.use('/api/setup', createSetupRoutes(db, config));
app.use('/api/admin/events', requireAuth, require('./admin/events-ops').createEventsOps({ db, eventsUrl: config.eventsInternalUrl, privateKey, issuer: config.jwt.issuer }));
// Service grants: owner-only, audited, network.principal_grant.changed (WS-D task 3).
app.use('/api/admin/grants', requireAuth, require('./identity/grants-admin').router(db));
++// Community theme review queue (WS-E task 2): submissions stay private until an admin approves them.
++app.use('/api/admin/themes', requireAuth, requireAdmin, require('./themes/routes').reviewRouter());
app.use('/api/admin', createAdminRoutes(db, notificationService, emailService, requireAuth));
// Analytics admin API
ddiff --git a/server/themes/routes.js b/server/themes/routes.js
iindex 799c8a51..747bda55 100644
---- a/server/themes/routes.js
++++ b/server/themes/routes.js
@@@ -7,8 +7,31 @@
const express = require('express');
const jwt = require('jsonwebtoken');
++const { cleanVariables } = require('./validate');
const router = express.Router();
++// Community themes are reviewed before anyone else sees them (roadmap WS-E task 2): a submission is
++// pending and private to its author until an admin approves it (GET/POST /api/admin/themes, reviewRouter
++// below). Built-in themes and anything that existed before the review columns count as approved.
++const EXPORT_FORMAT = 'openvibe-theme@1';
++const MAX_PENDING = 5;
++let reviewReady = false;
++function ensureReview(db) {
++ if (reviewReady) return;
++ for (const col of ["review_status TEXT NOT NULL DEFAULT 'approved'", 'reviewed_by INTEGER', 'reviewed_at DATETIME', 'review_note TEXT']) {
++ try { db.exec(`ALTER TABLE themes ADD COLUMN ${col}`); } catch { /* already there */ }
++ }
++ reviewReady = true;
++}
++const parseTheme = (t) => {
++ if (!t) return t;
++ try { t.variables = JSON.parse(t.variables); } catch { t.variables = {}; }
++ try { t.tags = JSON.parse(t.tags); } catch { t.tags = []; }
++ try { t.preview_colors = t.preview_colors ? JSON.parse(t.preview_colors) : null; } catch { t.preview_colors = null; }
++ return t;
++};
++const visibleTo = (t, userId) => t && ((t.is_public === 1 && t.review_status === 'approved') || (userId != null && String(t.author_id) === String(userId)));
++
function getDb(req) { return req.app.locals.db; }
function getConfig(req) { return req.app.locals.config; }
@@@ -40,9 +63,10 @@ function requireAuth(req, res, next) {
// ── List Themes ──────────────────────────────────────────────
router.get('/', (req, res) => {
const db = getDb(req);
++ ensureReview(db);
const { mode, search, sort, limit } = req.query;
-- let sql = 'SELECT id, name, slug, description, mode, variables, preview_colors, is_builtin, downloads, rating_sum, rating_count, tags FROM themes WHERE is_public = 1';
++ let sql = "SELECT id, name, slug, description, mode, variables, preview_colors, is_builtin, downloads, rating_sum, rating_count, tags FROM themes WHERE is_public = 1 AND review_status = 'approved'";
const params = [];
if (mode && (mode === 'dark' || mode === 'light')) {
@@@ -105,14 +129,33 @@ router.put('/me/display', requireAuth, (req, res) => {
res.json({ ok: true, display });
});
--// ── Get Theme by ID or Slug ──────────────────────────────────
--router.get('/:idOrSlug', (req, res) => {
++// ── Your submissions, with their review status ────────────────
++router.get('/me/submissions', requireAuth, (req, res) => {
++ const db = getDb(req);
++ ensureReview(db);
++ const userId = req.user.sub || req.user.id;
++ const themes = db.prepare('SELECT id, name, slug, description, mode, variables, tags, review_status, review_note, reviewed_at, created_at FROM themes WHERE author_id = ? AND is_builtin = 0 ORDER BY created_at DESC LIMIT 50').all(userId).map(parseTheme);
++ res.set('Cache-Control', 'private, no-store').json({ themes });
++});
++
++// ── Get Theme by ID or Slug (approved themes; your own at any status) ──
++router.get('/:idOrSlug', optionalAuth, (req, res) => {
const db = getDb(req);
++ ensureReview(db);
const theme = db.prepare('SELECT * FROM themes WHERE id = ? OR slug = ?').get(req.params.idOrSlug, req.params.idOrSlug);
-- if (!theme) return res.status(404).json({ error: 'Theme not found' });
-- try { theme.variables = JSON.parse(theme.variables); } catch { theme.variables = {}; }
-- try { theme.tags = JSON.parse(theme.tags); } catch { theme.tags = []; }
-- res.json({ theme });
++ if (!visibleTo(theme, req.user && (req.user.sub || req.user.id))) return res.status(404).json({ error: 'Theme not found' });
++ res.json({ theme: parseTheme(theme) });
++});
++
++// ── Export a theme as a file (import it elsewhere, or submit an edited copy) ──
++router.get('/:idOrSlug/export', optionalAuth, (req, res) => {
++ const db = getDb(req);
++ ensureReview(db);
++ const t = db.prepare('SELECT * FROM themes WHERE id = ? OR slug = ?').get(req.params.idOrSlug, req.params.idOrSlug);
++ if (!visibleTo(t, req.user && (req.user.sub || req.user.id))) return res.status(404).json({ error: 'Theme not found' });
++ parseTheme(t);
++ const file = { format: EXPORT_FORMAT, name: t.name, slug: t.slug, description: t.description || '', mode: t.mode, variables: t.variables, tags: t.tags };
++ res.set('Content-Disposition', `attachment; filename="${t.slug}.openvibe-theme.json"`).json(file);
});
// ── Set User's Active Theme ──────────────────────────────────
@@@ -121,9 +164,15 @@ router.put('/me', requireAuth, (req, res) => {
const userId = req.user.sub || req.user.id;
const { theme_id, custom_variables } = req.body;
++ ensureReview(db);
if (theme_id) {
-- const theme = db.prepare('SELECT id FROM themes WHERE id = ? OR slug = ?').get(theme_id, theme_id);
-- if (!theme) return res.status(404).json({ error: 'Theme not found' });
++ const theme = db.prepare('SELECT id, author_id, is_public, review_status FROM themes WHERE id = ? OR slug = ?').get(theme_id, theme_id);
++ if (!visibleTo(theme, userId)) return res.status(404).json({ error: 'Theme not found' });
++ }
++ if (custom_variables) {
++ const v = cleanVariables(custom_variables);
++ if (!v.ok) return res.status(422).json({ error: 'Custom colours rejected', code: 'theme.invalid_variables', errors: v.errors });
++ req.body.custom_variables = v.variables;
}
db.prepare(`
@@@ -136,36 +185,75 @@ router.put('/me', requireAuth, (req, res) => {
`).run(
userId,
theme_id || 'vibe',
-- custom_variables ? JSON.stringify(custom_variables) : null,
++ req.body.custom_variables ? JSON.stringify(req.body.custom_variables) : null,
theme_id || null,
-- custom_variables ? JSON.stringify(custom_variables) : null
++ req.body.custom_variables ? JSON.stringify(req.body.custom_variables) : null
);
-- res.json({ success: true, theme_id, custom_variables });
++ res.json({ success: true, theme_id, custom_variables: req.body.custom_variables || null });
});
--// ── Submit Community Theme ───────────────────────────────────
--router.post('/', requireAuth, (req, res) => {
-- const db = getDb(req);
-- const userId = req.user.sub || req.user.id;
-- const { name, slug, description, mode, variables, tags } = req.body;
--
-- if (!name || !slug || !variables) return res.status(400).json({ error: 'name, slug, and variables required' });
-- if (typeof variables !== 'object') return res.status(400).json({ error: 'variables must be an object' });
-- if (!/^[a-z0-9-]+$/.test(slug)) return res.status(400).json({ error: 'slug must be lowercase alphanumeric with hyphens' });
--
-- const existing = db.prepare('SELECT id FROM themes WHERE slug = ?').get(slug);
-- if (existing) return res.status(409).json({ error: 'A theme with that slug already exists' });
--
++// ── Submit a community theme (pending review) ─────────────────
++function submitTheme(db, userId, body) {
++ ensureReview(db);
++ const { name, slug, description, mode, variables, tags } = body || {};
++ if (!name || !slug || !variables) return [400, { error: 'name, slug, and variables required' }];
++ if (typeof name !== 'string' || name.trim().length < 2 || name.length > 60) return [400, { error: 'name is 2 to 60 characters' }];
++ if (typeof slug !== 'string' || !/^[a-z0-9-]{2,48}$/.test(slug)) return [400, { error: 'slug must be lowercase alphanumeric with hyphens' }];
++ if (mode && mode !== 'dark' && mode !== 'light') return [400, { error: 'mode is dark or light' }];
++ const v = cleanVariables(variables);
++ if (!v.ok) return [422, { error: 'Theme rejected', code: 'theme.invalid_variables', errors: v.errors }];
++ const cleanTags = Array.isArray(tags) ? tags.filter((t) => typeof t === 'string' && /^[a-z0-9-]{1,24}$/.test(t)).slice(0, 8) : [];
++ if (db.prepare('SELECT id FROM themes WHERE slug = ?').get(slug)) return [409, { error: 'A theme with that slug already exists' }];
++ const pending = db.prepare("SELECT COUNT(*) AS n FROM themes WHERE author_id = ? AND review_status = 'pending'").get(userId).n;
++ if (pending >= MAX_PENDING) return [429, { error: `You have ${pending} themes waiting for review; wait for those first`, code: 'theme.too_many_pending' }];
const id = `community-${slug}`;
-- db.prepare(`
-- INSERT INTO themes (id, name, slug, author_id, description, mode, variables, is_builtin, is_public, tags)
-- VALUES (?, ?, ?, ?, ?, ?, ?, 0, 1, ?)
-- `).run(id, name, slug, userId, description || '', mode || 'dark', JSON.stringify(variables), JSON.stringify(tags || []));
++ db.prepare(`INSERT INTO themes (id, name, slug, author_id, description, mode, variables, is_builtin, is_public, tags, review_status)
++ VALUES (?, ?, ?, ?, ?, ?, ?, 0, 0, ?, 'pending')`)
++ .run(id, name.trim(), slug, userId, String(description || '').slice(0, 300), mode || 'dark', JSON.stringify(v.variables), JSON.stringify(cleanTags));
++ return [201, { theme: parseTheme(db.prepare('SELECT * FROM themes WHERE id = ?').get(id)), review: 'pending', note: 'Submitted themes are reviewed before anyone else sees them. You can use yours right away.' }];
++}
-- const theme = db.prepare('SELECT * FROM themes WHERE id = ?').get(id);
-- try { theme.variables = JSON.parse(theme.variables); } catch {}
-- res.status(201).json({ theme });
++router.post('/', requireAuth, (req, res) => {
++ const [status, body] = submitTheme(getDb(req), req.user.sub || req.user.id, req.body);
++ res.status(status).json(body);
});
++// ── Import a theme file (openvibe-theme@1, as /:id/export writes it): a submission like any other ──
++router.post('/import', requireAuth, (req, res) => {
++ const file = req.body || {};
++ if (file.format !== EXPORT_FORMAT) return res.status(400).json({ error: `Not an ${EXPORT_FORMAT} file`, code: 'theme.bad_format' });
++ const [status, body] = submitTheme(getDb(req), req.user.sub || req.user.id, file);
++ res.status(status).json(body);
++});
++
++// ── Review queue (admins; mounted at /api/admin/themes behind requireAuth + requireAdmin) ──
++function reviewRouter() {
++ const r = express.Router();
++ r.get('/', (req, res) => {
++ const db = getDb(req);
++ ensureReview(db);
++ const status = ['pending', 'approved', 'rejected'].includes(req.query.status) ? req.query.status : 'pending';
++ const themes = db.prepare(`SELECT t.id, t.name, t.slug, t.description, t.mode, t.variables, t.tags, t.review_status, t.review_note, t.reviewed_at, t.created_at, u.username AS author
++ FROM themes t LEFT JOIN users u ON u.id = t.author_id WHERE t.is_builtin = 0 AND t.review_status = ? ORDER BY t.created_at ASC LIMIT 200`).all(status).map(parseTheme);
++ res.set('Cache-Control', 'private, no-store').json({ status, themes });
++ });
++ r.post('/:id/review', (req, res) => {
++ const db = getDb(req);
++ ensureReview(db);
++ const decision = req.body && req.body.decision;
++ if (!['approve', 'reject'].includes(decision)) return res.status(400).json({ error: 'decision is approve or reject' });
++ const note = String((req.body && req.body.note) || '').slice(0, 300) || null;
++ if (decision === 'reject' && !note) return res.status(400).json({ error: 'Say why it is rejected (note)', code: 'theme.note_required' });
++ const t = db.prepare('SELECT id, is_builtin FROM themes WHERE id = ?').get(req.params.id);
++ if (!t || t.is_builtin) return res.status(404).json({ error: 'No such community theme' });
++ db.prepare("UPDATE themes SET review_status = ?, is_public = ?, reviewed_by = ?, reviewed_at = CURRENT_TIMESTAMP, review_note = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
++ .run(decision === 'approve' ? 'approved' : 'rejected', decision === 'approve' ? 1 : 0, req.user.id || req.user.sub, note, t.id);
++ res.json({ theme: parseTheme(db.prepare('SELECT * FROM themes WHERE id = ?').get(t.id)) });
++ });
++ return r;
++}
++
module.exports = router;
++module.exports.reviewRouter = reviewRouter;
++module.exports.submitTheme = submitTheme;
ddiff --git a/server/themes/validate.js b/server/themes/validate.js
nnew file mode 100644
iindex 00000000..7567a005
---- /dev/null
++++ b/server/themes/validate.js
@@@ -0,0 +1,49 @@
++'use strict';
++/**
++ * What a community theme (or a person's custom overrides) may contain (roadmap WS-E task 2). A theme is
++ * applied by setting CSS custom properties on every OpenVibe site, so both halves are allow-listed:
++ *
++ * names the token vocabulary openvibe-shared ships (its default variables and every built-in
++ * theme's), nothing else: a theme cannot set arbitrary properties
++ * values colours, numbers, lengths and shadows only: hex, rgb()/rgba()/hsl()/hsla(), plain numbers
++ * (e.g. --accent-rgb "91, 124, 250"), px/rem/em/%, and the keywords below. No url(), var(),
++ * expression(), attr(), quotes, semicolons or braces, so a value cannot fetch anything or
++ * break out of its declaration.
++ *
++ * cleanVariables(vars) → { ok, variables, errors }: variables are the accepted pairs (trimmed).
++ */
++const shared = require('openvibe-shared/builtin-themes');
++
++const TOKENS = new Set([
++ ...Object.keys(shared.DEFAULT_VARS || {}),
++ ...(shared.BUILTIN_THEMES || []).flatMap((t) => Object.keys((t && t.variables) || {})),
++]);
++const MAX_KEYS = 60;
++const MAX_VALUE = 120;
++const CHARS = /^[#a-z0-9%.,()\s/+-]+$/i;
++const WORDS = new Set(['rgb', 'rgba', 'hsl', 'hsla', 'px', 'rem', 'em', 'vh', 'vw', 'deg', 'inset', 'transparent', 'none', 'dark', 'light', 'currentcolor']);
++
++function valueOk(v) {
++ if (typeof v !== 'string' && typeof v !== 'number') return false;
++ const s = String(v).trim();
++ if (!s || s.length > MAX_VALUE || !CHARS.test(s)) return false;
++ const words = s.replace(/#[0-9a-f]{3,8}\b/gi, ' ').match(/[a-z]+/gi) || [];
++ return words.every((w) => WORDS.has(w.toLowerCase()));
++}
++
++function cleanVariables(vars) {
++ const errors = [];
++ if (!vars || typeof vars !== 'object' || Array.isArray(vars)) return { ok: false, variables: {}, errors: ['variables must be an object of CSS custom properties'] };
++ const entries = Object.entries(vars);
++ if (entries.length > MAX_KEYS) errors.push(`at most ${MAX_KEYS} variables`);
++ const out = {};
++ for (const [k, v] of entries.slice(0, MAX_KEYS)) {
++ if (!TOKENS.has(k)) { errors.push(`${String(k).slice(0, 60)} is not a theme token`); continue; }
++ if (!valueOk(v)) { errors.push(`${k}: only colours, numbers, lengths and shadows are allowed`); continue; }
++ out[k] = String(v).trim();
++ }
++ if (!Object.keys(out).length && !errors.length) errors.push('no theme tokens given');
++ return { ok: errors.length === 0, variables: out, errors };
++}
++
++module.exports = { cleanVariables, TOKENS, valueOk };
ddiff --git a/test/themes-review.test.js b/test/themes-review.test.js
nnew file mode 100644
iindex 00000000..4632f832
---- /dev/null
++++ b/test/themes-review.test.js
@@@ -0,0 +1,98 @@
++'use strict';
++// Community themes (roadmap WS-E task 2): a submission is pending and private to its author until an
++// admin approves it; the catalog shows approved themes only; theme values are allow-listed (only the
++// shared token names, only colours, numbers, lengths and shadows); a theme exports as an
++// openvibe-theme@1 file and imports as a new submission; custom overrides are cleaned the same way.
++// node test/themes-review.test.js
++const assert = require('assert');
++const crypto = require('crypto');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++const express = require('express');
++const { initDb } = require('../server/db/database');
++
++const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-themes-'));
++const log = console.log; console.log = () => {};
++const db = initDb(path.join(dir, 'network.db'));
++console.log = log;
++db.prepare("INSERT INTO users (id, username, password_hash, role) VALUES (1, 'ann', 'x', 'user'), (2, 'bob', 'x', 'user'), (3, 'boss', 'x', 'admin')").run();
++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
++const config = { internalKey: 'legacy-key', jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };
++const { signToken } = require('../server/auth/routes');
++const requireAuth = require('../server/auth/session').makeRequireAuth(() => ({ db, publicKey: keys.publicKey, config }), signToken);
++const requireAdmin = (req, res, next) => (req.user && req.user.role === 'admin' ? next() : res.status(403).json({ error: 'admin only' }));
++const themes = require('../server/themes/routes');
++const app = express();
++app.use(express.json());
++Object.assign(app.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });
++app.use('/api/themes', themes);
++app.use('/api/admin/themes', requireAuth, requireAdmin, themes.reviewRouter());
++const server = http.createServer(app);
++const token = (id) => signToken(db.prepare('SELECT * FROM users WHERE id = ?').get(id), keys.privateKey, config);
++
++(async () => {
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const call = (method, p, { body, as } = {}) => fetch(base + p, { method, headers: { 'content-type': 'application/json', ...(as ? { authorization: `Bearer ${token(as)}` } : {}) }, body: body ? JSON.stringify(body) : undefined })
++ .then(async (r) => ({ status: r.status, headers: r.headers, body: await r.json().catch(() => null) }));
++ db.prepare("INSERT OR IGNORE INTO themes (id, name, slug, mode, variables, is_builtin, is_public) VALUES ('vibe', 'Vibe', 'vibe', 'dark', '{\"--accent\":\"#5b7cfa\"}', 1, 1)").run();
++
++ // ── Values are allow-listed ──
++ const good = { '--accent': '#ff6a3d', '--bg-primary': '#101010', '--accent-rgb': '255, 106, 61', '--shadow': '0 8px 24px rgba(0, 0, 0, .4)' };
++ for (const [bad, why] of [[{ '--accent': 'url(https://evil.example/t.png)' }, 'url()'], [{ '--accent': 'red; } body { display: none' }, 'break-out'], [{ '--accent': 'var(--x)' }, 'var()'], [{ '--made-up': '#fff' }, 'unknown token']]) {
++ const r = await call('POST', '/api/themes', { as: 1, body: { name: 'Bad', slug: `bad-${why.replace(/\W/g, '')}`, variables: bad } });
++ assert.strictEqual(r.status, 422, `${why} is refused`);
++ }
++
++ // ── A submission is pending and private to its author ──
++ let r = await call('POST', '/api/themes', { as: 1, body: { name: 'Ember Night', slug: 'ember-night', variables: good, tags: ['warm', 'Bad Tag!'] } });
++ assert.strictEqual(r.status, 201, JSON.stringify(r.body));
++ assert.strictEqual(r.body.review, 'pending');
++ assert.deepStrictEqual(r.body.theme.tags, ['warm'], 'tags are cleaned');
++ assert.ok(!(await call('GET', '/api/themes')).body.themes.some((t) => t.slug === 'ember-night'), 'not in the catalog yet');
++ assert.strictEqual((await call('GET', '/api/themes/ember-night', { as: 2 })).status, 404, 'hidden from others');
++ assert.strictEqual((await call('GET', '/api/themes/ember-night')).status, 404, 'and from visitors');
++ assert.strictEqual((await call('GET', '/api/themes/ember-night', { as: 1 })).status, 200, 'the author sees it');
++ assert.strictEqual((await call('PUT', '/api/themes/me', { as: 1, body: { theme_id: 'ember-night' } })).status, 200, 'the author can use it now');
++ assert.strictEqual((await call('PUT', '/api/themes/me', { as: 2, body: { theme_id: 'ember-night' } })).status, 404, 'nobody else can');
++ assert.strictEqual((await call('GET', '/api/themes/me/submissions', { as: 1 })).body.themes[0].review_status, 'pending');
++
++ // ── Export and import ──
++ r = await call('GET', '/api/themes/ember-night/export', { as: 1 });
++ assert.strictEqual(r.status, 200);
++ assert.match(r.headers.get('content-disposition'), /ember-night\.openvibe-theme\.json/);
++ assert.strictEqual(r.body.format, 'openvibe-theme@1');
++ const file = { ...r.body, slug: 'ember-night-copy', name: 'Ember Night Copy' };
++ assert.strictEqual((await call('POST', '/api/themes/import', { as: 2, body: { ...file, format: 'something-else' } })).status, 400);
++ r = await call('POST', '/api/themes/import', { as: 2, body: file });
++ assert.strictEqual(r.status, 201, JSON.stringify(r.body));
++ assert.strictEqual(r.body.review, 'pending', 'an import is a submission like any other');
++
++ // ── Review ──
++ assert.strictEqual((await call('GET', '/api/admin/themes', { as: 1 })).status, 403, 'admins only');
++ r = await call('GET', '/api/admin/themes', { as: 3 });
++ assert.deepStrictEqual(r.body.themes.map((t) => [t.slug, t.author]), [['ember-night', 'ann'], ['ember-night-copy', 'bob']]);
++ assert.strictEqual((await call('POST', '/api/admin/themes/community-ember-night-copy/review', { as: 3, body: { decision: 'reject' } })).status, 400, 'a rejection says why');
++ r = await call('POST', '/api/admin/themes/community-ember-night-copy/review', { as: 3, body: { decision: 'reject', note: 'a copy of ember-night' } });
++ assert.strictEqual(r.body.theme.review_status, 'rejected');
++ r = await call('POST', '/api/admin/themes/community-ember-night/review', { as: 3, body: { decision: 'approve' } });
++ assert.strictEqual(r.body.theme.review_status, 'approved');
++ assert.ok((await call('GET', '/api/themes')).body.themes.some((t) => t.slug === 'ember-night'), 'approved: in the catalog');
++ assert.strictEqual((await call('GET', '/api/themes/ember-night', { as: 2 })).status, 200);
++ assert.strictEqual((await call('GET', '/api/themes/ember-night-copy', { as: 3 })).status, 404, 'rejected stays private');
++ assert.strictEqual((await call('POST', '/api/admin/themes/vibe/review', { as: 3, body: { decision: 'reject', note: 'x' } })).status, 404, 'built-ins are not reviewed');
++
++ // ── Custom overrides are cleaned the same way; the pending limit holds ──
++ assert.strictEqual((await call('PUT', '/api/themes/me', { as: 2, body: { theme_id: 'vibe', custom_variables: { '--accent': 'url(x)' } } })).status, 422);
++ r = await call('PUT', '/api/themes/me', { as: 2, body: { theme_id: 'vibe', custom_variables: { '--accent': '#00ff88' } } });
++ assert.deepStrictEqual(r.body.custom_variables, { '--accent': '#00ff88' });
++ for (let i = 0; i < 5; i++) await call('POST', '/api/themes', { as: 2, body: { name: `Try ${i}`, slug: `try-${i}`, variables: good } });
++ assert.strictEqual((await call('POST', '/api/themes', { as: 2, body: { name: 'One more', slug: 'one-more', variables: good } })).status, 429, 'at most five waiting');
++
++ server.close();
++ fs.rmSync(dir, { recursive: true, force: true });
++ console.log('themes review: all checks passed');
++ process.exit(0);
++})().catch((err) => { console.error(err); process.exit(1); });