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
Changes
@@@ -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"},@@@ -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');@@@ -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();@@@ -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@@@ -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;nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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); });
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
"dev": "NODE_ENV=development 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",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
"grant-admin": "node server/grant-admin.js"
},
},
@@ -214,6 +214,7 @@ body{min-height:100%;color:var(--text);font-family:'Inter',-apple-system,BlinkMa
@@ -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('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" 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 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 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" 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>
<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
@@ -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 style="display:flex;justify-content:center;margin-top:12px"><button id="modlog-more" class="btn" hidden onclick="loadModLog(false)">Load more</button></div>
</div>
</div>
+ <!-- Community theme review (WS-E task 2): submissions stay private until approved here -->
+ <div class="section" id="sec-themes">
+ <p class="muted" style="margin:0 0 10px">Community themes waiting for review. Approving one puts it in the public catalog; rejecting needs a reason, which the author sees. Values are already limited to the shared colour tokens.</p>
+ <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 -->
<!-- Service grants (WS-D task 3): what each service principal may do, owner-only and audited -->
<div class="section" id="sec-grants">
<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>
<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 = {
@@ -856,6 +863,7 @@ const tabLoaders = {
audit: loadAudit,
audit: loadAudit,
modlog: () => loadModLog(true),
modlog: () => loadModLog(true),
grants: () => loadGrants(),
grants: () => loadGrants(),
+ themes: () => loadThemeReview(),
eventsops: () => loadDeliveries(),
eventsops: () => loadDeliveries(),
analytics: loadAnalytics,
analytics: loadAnalytics,
deploy: loadDeployOverview,
deploy: loadDeployOverview,
@@ -3650,6 +3658,36 @@ async function replayDeliveries(sub, ids) {
@@ -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(); }
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'); }
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 = [];
let _grants = [];
async function loadGrants() {
async function loadGrants() {
const c = document.getElementById('grants-content');
const c = document.getElementById('grants-content');
@@ -376,6 +376,21 @@ a.linked-item:hover{background:var(--bg-hover,var(--bg-card));transform:translat
@@ -376,6 +376,21 @@ a.linked-item:hover{background:var(--bg-hover,var(--bg-card));transform:translat
</div>
</div>
</div>
</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>
</div>
<!-- Accounts Section -->
<!-- Accounts Section -->
@@ -1023,6 +1038,72 @@ async function saveCustomColors(btn) {
@@ -1023,6 +1038,72 @@ async function saveCustomColors(btn) {
} catch (err) { alert('Error: ' + err.message); }
} catch (err) { alert('Error: ' + err.message); }
finally { if (btn) btn.disabled = false; }
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) {
async function resetCustomColors(btn) {
if (btn) btn.disabled = true;
if (btn) btn.disabled = true;
try {
try {
@@ -1122,7 +1203,7 @@ async function loadUser() {
@@ -1122,7 +1203,7 @@ async function loadUser() {
currentUser = data.user;
currentUser = data.user;
renderHeader(data.user);
renderHeader(data.user);
renderProfile(data.user);
renderProfile(data.user);
- loadThemes();
+ loadThemes(); loadThemeSubmissions();
loadAccounts();
loadAccounts();
loadNotifPrefs();
loadNotifPrefs();
loadLinked();
loadLinked();
@@ -572,6 +572,8 @@ app.use('/api/setup', createSetupRoutes(db, config));
@@ -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 }));
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).
// 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));
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));
app.use('/api/admin', createAdminRoutes(db, notificationService, emailService, requireAuth));
// Analytics admin API
// Analytics admin API
@@ -7,8 +7,31 @@
@@ -7,8 +7,31 @@
const express = require('express');
const express = require('express');
const jwt = require('jsonwebtoken');
const jwt = require('jsonwebtoken');
+const { cleanVariables } = require('./validate');
const router = express.Router();
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 getDb(req) { return req.app.locals.db; }
function getConfig(req) { return req.app.locals.config; }
function getConfig(req) { return req.app.locals.config; }
@@ -40,9 +63,10 @@ function requireAuth(req, res, next) {
@@ -40,9 +63,10 @@ function requireAuth(req, res, next) {
// ── List Themes ──────────────────────────────────────────────
// ── List Themes ──────────────────────────────────────────────
router.get('/', (req, res) => {
router.get('/', (req, res) => {
const db = getDb(req);
const db = getDb(req);
+ ensureReview(db);
const { mode, search, sort, limit } = req.query;
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 = [];
const params = [];
if (mode && (mode === 'dark' || mode === 'light')) {
if (mode && (mode === 'dark' || mode === 'light')) {
@@ -105,14 +129,33 @@ router.put('/me/display', requireAuth, (req, res) => {
@@ -105,14 +129,33 @@ router.put('/me/display', requireAuth, (req, res) => {
res.json({ ok: true, display });
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);
const db = getDb(req);
+ ensureReview(db);
const theme = db.prepare('SELECT * FROM themes WHERE id = ? OR slug = ?').get(req.params.idOrSlug, req.params.idOrSlug);
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 ──────────────────────────────────
// ── Set User's Active Theme ──────────────────────────────────
@@ -121,9 +164,15 @@ router.put('/me', requireAuth, (req, res) => {
@@ -121,9 +164,15 @@ router.put('/me', requireAuth, (req, res) => {
const userId = req.user.sub || req.user.id;
const userId = req.user.sub || req.user.id;
const { theme_id, custom_variables } = req.body;
const { theme_id, custom_variables } = req.body;
+ ensureReview(db);
if (theme_id) {
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(`
db.prepare(`
@@ -136,36 +185,75 @@ router.put('/me', requireAuth, (req, res) => {
@@ -136,36 +185,75 @@ router.put('/me', requireAuth, (req, res) => {
`).run(
`).run(
userId,
userId,
theme_id || 'vibe',
theme_id || 'vibe',
- custom_variables ? JSON.stringify(custom_variables) : null,
+ req.body.custom_variables ? JSON.stringify(req.body.custom_variables) : null,
theme_id || 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}`;
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 = router;
+module.exports.reviewRouter = reviewRouter;
+module.exports.submitTheme = submitTheme;
new file mode 100644
new file mode 100644
@@ -0,0 +1,49 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,98 @@
@@ -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); });