openvibers/openvibe.network Commits

Label preferences API (L1): show, blur or hide for each label (#450)

OP OpenVibers committed 11 days ago · 5ed7a66d8c · 4 files, +227 −1

* Label preferences: GET and PUT /api/v1/me/label-prefs (L1)

Each person chooses show, blur or hide for the six content labels. The choices live in the existing
user_preferences.site_prefs JSON (migration 0029) so one setting covers every OpenVibe site, and the site
switcher's write now keeps keys it does not own (and vice versa) instead of overwriting the whole column.

GET fills an unset label with the web default, show; PUT takes exactly the six in network.label-prefs@1
(unknown label, choice or a missing label is a 422 problem). The id comes from the session: only the person
reads or writes their own. The label list is local until the pin round to contracts 0.257.0, and
test/label-prefs.test.js holds it to the plan.

* Merge remote-tracking branch 'origin/main' into refresh-450

Changes

ddiff --git a/server/index.js b/server/index.js
iindex cfc18863..dc2f2dc6 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -677,6 +677,11 @@ const consent = require('./consent').createConsent({ db });
app.use('/api/v1/me/consent', require('./consent').router({ consent, requireAuth }));
app.use('/internal/consent', require('./identity/principals').guard('network.consent.read'), require('./consent').internalRouter({ consent }));
++// Label preferences (L1, 2026-10-31; contracts 0.257.0 network.label-prefs@1): show, blur or hide for each of
++// the six labels, stored in the person's site prefs so one choice covers every OpenVibe site. Session only.
++const labelPrefs = require('./labels').createLabelPrefs({ db });
++app.use('/api/v1/me/label-prefs', require('./labels').router({ labelPrefs, requireAuth }));
++
// Notification API (authenticated users)
app.use('/api/notifications', createNotificationRoutes(db, notificationService, requireAuth, requireDock));
ddiff --git a/server/labels/index.js b/server/labels/index.js
nnew file mode 100644
iindex 00000000..a83158a3
---- /dev/null
++++ b/server/labels/index.js
@@@ -0,0 +1,121 @@
++'use strict';
++/**
++ * Label preferences (L1, labels and choices, 2026-10-31; contracts 0.257.0 network.label-prefs@1).
++ *
++ * The labels themselves are not a ban: a creator labels a stream, VOD, clip, picture or post, and each viewer
++ * chooses what to do with that label on every OpenVibe site. The choice is one value per label — show, blur
++ * (until tapped) or hide — and it lives in the person's existing site prefs (migration 0029's `site_prefs`),
++ * so the site switcher and these prefs share one row without either dropping the other's keys.
++ *
++ * mine(actor) → { prefs: { <label>: 'show'|'blur'|'hide' } } (network.label-prefs@1)
++ * save(actor, prefs) → the same shape, after replacing the person's choices
++ *
++ * The web default is show for every label; a person who never chose gets all six at show. Only the signed-in
++ * person reads or writes their own prefs: the id comes from the session, never from the request.
++ */
++const express = require('express');
++const { http } = require('openvibe-contracts');
++
++const CONTRACT = 'network.label-prefs@1';
++
++// The one label list (contracts 0.257.0 common.content-labels@1). Kept here until the pin round to 0.257.0,
++// where it moves to `require('openvibe-contracts').labels`; test/label-prefs.test.js holds it to the plan.
++const LABELS = Object.freeze(['nudity', 'language', 'graphic', '18+', 'flashing', 'spoilers']);
++const CHOICES = Object.freeze(['show', 'blur', 'hide']);
++const DEFAULT = 'show';
++const KEY = 'labels'; // the key inside user_preferences.site_prefs
++
++/** The plain words the account page shows. The API carries the choices; this is the copy a page needs. */
++const INFO = Object.freeze([
++ Object.freeze({ id: 'nudity', title: 'Nudity', description: 'Nudity and sexual content.' }),
++ Object.freeze({ id: 'language', title: 'Strong language', description: 'Swearing and strong language.' }),
++ Object.freeze({ id: 'graphic', title: 'Graphic violence', description: 'Graphic violence or injury.' }),
++ Object.freeze({ id: '18+', title: '18+', description: 'For adults only.' }),
++ Object.freeze({ id: 'flashing', title: 'Flashing lights', description: 'Flashing lights or rapid motion.' }),
++ Object.freeze({ id: 'spoilers', title: 'Spoilers', description: 'Gives away a story.' }),
++]);
++
++const defaults = () => Object.fromEntries(LABELS.map((label) => [label, DEFAULT]));
++
++/** The contract's shape: exactly the six labels, each one of the three choices. */
++function parsePrefs(body) {
++ const prefs = body && typeof body === 'object' && !Array.isArray(body) ? body.prefs : null;
++ if (!prefs || typeof prefs !== 'object' || Array.isArray(prefs)) return { ok: false, error: 'prefs must be an object with every label' };
++ const keys = Object.keys(prefs);
++ const extra = keys.filter((k) => !LABELS.includes(k));
++ const missing = LABELS.filter((k) => !keys.includes(k));
++ if (extra.length) return { ok: false, error: `unknown label: ${extra.join(', ')}` };
++ if (missing.length) return { ok: false, error: `missing label: ${missing.join(', ')}` };
++ const clean = {};
++ for (const label of LABELS) {
++ if (!CHOICES.includes(prefs[label])) return { ok: false, error: `${label} must be show, blur or hide` };
++ clean[label] = prefs[label];
++ }
++ return { ok: true, prefs: clean };
++}
++
++/** The site_prefs JSON, whatever else it holds. A bad value is treated as unset, never an error page. */
++const sitePrefsOf = (raw) => {
++ if (raw && typeof raw === 'object' && !Array.isArray(raw)) return raw;
++ if (typeof raw !== 'string') return {};
++ try { const v = JSON.parse(raw); return v && typeof v === 'object' && !Array.isArray(v) ? v : {}; } catch { return {}; }
++};
++
++function createLabelPrefs({ db }) {
++ async function read(userId) {
++ const row = await db.prepare('SELECT site_prefs FROM user_preferences WHERE user_id = ?').get(userId);
++ return sitePrefsOf(row && row.site_prefs);
++ }
++
++ /** network.label-prefs@1: all six, filling a missing (or older, unknown) value with the web default. */
++ async function mine(userId) {
++ const stored = sitePrefsOf((await read(userId))[KEY]);
++ const prefs = defaults();
++ for (const label of LABELS) if (CHOICES.includes(stored[label])) prefs[label] = stored[label];
++ return { prefs };
++ }
++
++ /** Replace the person's choices; every other key in site_prefs (pinned and recent sites) stays. */
++ async function save(userId, prefs) {
++ const current = await read(userId);
++ const next = { ...current, [KEY]: { ...prefs } };
++ await db.prepare(`INSERT INTO user_preferences (user_id, site_prefs, updated_at) VALUES (?, ?, ov_now())
++ ON CONFLICT(user_id) DO UPDATE SET site_prefs = excluded.site_prefs, updated_at = ov_now()`).run(userId, JSON.stringify(next));
++ return { prefs: { ...prefs } };
++ }
++
++ return { mine, save, _read: read };
++}
++
++/** The person's own routes, behind Network's session check, and never cached. */
++function router({ labelPrefs, requireAuth }) {
++ const r = express.Router();
++ r.use(http.middleware());
++ r.use(express.json({ limit: '4kb' }));
++ r.use((req, res, next) => { res.set('Cache-Control', 'private, no-store'); next(); });
++ const userIdOf = (req) => req.user.sub || req.user.id;
++
++ r.get('/', requireAuth, async (req, res) => {
++ try {
++ res.json(await labelPrefs.mine(userIdOf(req)));
++ } catch (err) {
++ console.error('[Labels] read:', err);
++ http.sendProblem(res, 500, 'label-prefs.error', { detail: 'could not read your label choices', ctx: req.ov });
++ }
++ });
++
++ r.put('/', requireAuth, async (req, res) => {
++ const parsed = parsePrefs(req.body);
++ if (!parsed.ok) return http.sendProblem(res, 422, 'label-prefs.invalid', { detail: parsed.error, ctx: req.ov });
++ try {
++ res.json(await labelPrefs.save(userIdOf(req), parsed.prefs));
++ } catch (err) {
++ console.error('[Labels] save:', err);
++ http.sendProblem(res, 500, 'label-prefs.error', { detail: 'could not save your label choices', ctx: req.ov });
++ }
++ });
++
++ return r;
++}
++
++module.exports = { CONTRACT, LABELS, CHOICES, DEFAULT, INFO, defaults, parsePrefs, sitePrefsOf, createLabelPrefs, router };
ddiff --git a/server/themes/routes.js b/server/themes/routes.js
iindex 824cae8d..9ce6c099 100644
---- a/server/themes/routes.js
++++ b/server/themes/routes.js
@@@ -218,8 +218,12 @@ async function readSites(db, userId) {
catch { return cleanSites(null); } // the column arrives with migration 0029
}
async function writeSites(db, userId, sites) {
++ // site_prefs holds more than the switcher (the label choices, L1): keep every key this route does not own.
++ let current = {};
++ try { const row = await db.prepare('SELECT site_prefs FROM user_preferences WHERE user_id = ?').get(userId); current = row && row.site_prefs ? JSON.parse(row.site_prefs) : {}; } catch { current = {}; }
++ const next = { ...(current && typeof current === 'object' && !Array.isArray(current) ? current : {}), pinned: sites.pinned, recent: sites.recent };
await db.prepare(`INSERT INTO user_preferences (user_id, site_prefs, updated_at) VALUES (?, ?, ov_now())
-- ON CONFLICT(user_id) DO UPDATE SET site_prefs = excluded.site_prefs, updated_at = ov_now()`).run(userId, JSON.stringify(sites));
++ ON CONFLICT(user_id) DO UPDATE SET site_prefs = excluded.site_prefs, updated_at = ov_now()`).run(userId, JSON.stringify(next));
return sites;
}
router.get('/me/sites', requireAuth, async (req, res) => {
ddiff --git a/test/label-prefs.test.js b/test/label-prefs.test.js
nnew file mode 100644
iindex 00000000..658f30de
---- /dev/null
++++ b/test/label-prefs.test.js
@@@ -0,0 +1,96 @@
++'use strict';
++// Label preferences (L1, 2026-10-31; contracts 0.257.0 network.label-prefs@1): show, blur or hide for each of the six
++// labels, one setting per person that follows the account across every site. Only the person reads or writes their
++// own; an unset label is show; the choice shares `user_preferences.site_prefs` with the site switcher without either
++// route dropping the other's keys.
++// node test/label-prefs.test.js
++const assert = require('assert');
++const crypto = require('crypto');
++const http = require('http');
++const express = require('express');
++const { getDb } = require('../server/db/database');
++
++const LABELS = ['nudity', 'language', 'graphic', '18+', 'flashing', 'spoilers'];
++const SHOW = Object.fromEntries(LABELS.map((l) => [l, 'show']));
++
++(async () => {
++ const db = getDb();
++ const log = console.log; console.log = () => {};
++ await db.prepare("INSERT INTO users (id, username, password_hash, role) VALUES (1, 'ann', 'x', 'user'), (2, 'bob', 'x', 'user')").run();
++ console.log = log;
++
++ const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
++ const config = { 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 labels = require('../server/labels');
++ 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/v1/me/label-prefs', labels.router({ labelPrefs: labels.createLabelPrefs({ db }), requireAuth }));
++ app.use('/api/themes', themes);
++ const server = http.createServer(app);
++ const token = async (id) => signToken(await db.prepare('SELECT * FROM users WHERE id = ?').get(id), keys.privateKey, config);
++
++ try {
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const call = async (method, p, { body, as } = {}) => fetch(base + p, { method, headers: { 'content-type': 'application/json', ...(as ? { authorization: `Bearer ${await token(as)}` } : {}) }, body: body ? JSON.stringify(body) : undefined })
++ .then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
++
++ // Only the person's own: no session, no prefs.
++ assert.strictEqual((await call('GET', '/api/v1/me/label-prefs')).status, 401, 'label prefs need a session');
++ assert.strictEqual((await call('PUT', '/api/v1/me/label-prefs', { body: { prefs: SHOW } })).status, 401, 'and a session writes them');
++
++ // The web default is show for every label; a person who never chose is not asked to.
++ let r = await call('GET', '/api/v1/me/label-prefs', { as: 1 });
++ assert.strictEqual(r.status, 200);
++ assert.deepStrictEqual(r.body.prefs, SHOW, 'an unset label is show');
++
++ // The person sets a mix; it reads back for them and never for anyone else.
++ const mixed = { ...SHOW, nudity: 'hide', graphic: 'blur', '18+': 'hide', spoilers: 'blur' };
++ r = await call('PUT', '/api/v1/me/label-prefs', { as: 1, body: { prefs: mixed } });
++ assert.strictEqual(r.status, 200);
++ assert.deepStrictEqual(r.body.prefs, mixed, 'the PUT answers the stored choices');
++ assert.deepStrictEqual((await call('GET', '/api/v1/me/label-prefs', { as: 1 })).body.prefs, mixed, 'the choice follows the person');
++ assert.deepStrictEqual((await call('GET', '/api/v1/me/label-prefs', { as: 2 })).body.prefs, SHOW, "another person's prefs are their own");
++ assert.strictEqual((await call('GET', '/api/v1/me/label-prefs', { as: 2 })).body.prefs.nudity, 'show', 'the default is not copied from anyone');
++
++ // A contract's shape is enforced: six labels, three choices, no invented ones.
++ for (const [body, why] of [
++ [{ prefs: { ...SHOW, nudity: 'sometimes' } }, 'an unknown choice'],
++ [{ prefs: { ...SHOW, gore: 'hide' } }, 'an unknown label'],
++ [{ prefs: Object.fromEntries(LABELS.slice(1).map((l) => [l, 'show'])) }, 'a missing label'],
++ [{ prefs: null }, 'no prefs object'],
++ ]) {
++ const bad = await call('PUT', '/api/v1/me/label-prefs', { as: 1, body });
++ assert.strictEqual(bad.status, 422, `${why} is refused`);
++ assert.strictEqual(bad.body.code, 'label-prefs.invalid', `${why} says why`);
++ }
++ assert.deepStrictEqual((await call('GET', '/api/v1/me/label-prefs', { as: 1 })).body.prefs, mixed, 'a refused write changed nothing');
++
++ // One row, two features: the site switcher and the label choices keep each other's keys.
++ r = await call('PUT', '/api/themes/me/sites', { as: 1, body: { pinned: ['live', 'tools'] } });
++ assert.strictEqual(r.status, 200);
++ assert.deepStrictEqual((await call('GET', '/api/v1/me/label-prefs', { as: 1 })).body.prefs, mixed, 'pinning a site keeps the label choices');
++ await call('PUT', '/api/v1/me/label-prefs', { as: 1, body: { prefs: { ...SHOW, language: 'blur' } } });
++ r = await call('GET', '/api/themes/me/sites', { as: 1 });
++ assert.deepStrictEqual(r.body.pinned, ['live', 'tools'], 'saving label choices keeps the pinned sites');
++ const row = await db.prepare('SELECT site_prefs FROM user_preferences WHERE user_id = 1').get();
++ const stored = JSON.parse(row.site_prefs);
++ assert.deepStrictEqual(Object.keys(stored).sort(), ['labels', 'pinned', 'recent'], 'site_prefs holds the switcher and the labels');
++ assert.strictEqual(stored.labels.language, 'blur', 'the label choices are in the existing site prefs (migration 0029)');
++
++ // The list itself: six labels and three choices, the same as common.content-labels@1 and the plan.
++ assert.deepStrictEqual(labels.LABELS, LABELS);
++ assert.deepStrictEqual(labels.CHOICES, ['show', 'blur', 'hide']);
++ assert.deepStrictEqual(labels.INFO.map((x) => x.id), LABELS);
++ assert.deepStrictEqual(labels.defaults(), SHOW);
++ assert.strictEqual(labels.parsePrefs({ prefs: SHOW }).ok, true);
++ } finally {
++ await new Promise((r) => server.close(r));
++ }
++ console.log('label prefs: six labels, show/blur/hide, only your own, stored in site_prefs');
++})().catch((err) => { console.error(err); process.exit(1); });