openvibers/openvibe.network Commits
Label preferences API (L1): show, blur or hide for each label (#450)
Changes
@@@ -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));nnew file mode 100644@@@ -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 };@@@ -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) => {nnew file mode 100644@@@ -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); });
@@ -677,6 +677,11 @@ const consent = require('./consent').createConsent({ db });
@@ -677,6 +677,11 @@ const consent = require('./consent').createConsent({ db });
app.use('/api/v1/me/consent', require('./consent').router({ consent, requireAuth }));
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 }));
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)
// Notification API (authenticated users)
app.use('/api/notifications', createNotificationRoutes(db, notificationService, requireAuth, requireDock));
app.use('/api/notifications', createNotificationRoutes(db, notificationService, requireAuth, requireDock));
new file mode 100644
new file mode 100644
@@ -0,0 +1,121 @@
@@ -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 };
@@ -218,8 +218,12 @@ async function readSites(db, userId) {
@@ -218,8 +218,12 @@ async function readSites(db, userId) {
catch { return cleanSites(null); } // the column arrives with migration 0029
catch { return cleanSites(null); } // the column arrives with migration 0029
}
}
async function writeSites(db, userId, sites) {
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())
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;
return sites;
}
}
router.get('/me/sites', requireAuth, async (req, res) => {
router.get('/me/sites', requireAuth, async (req, res) => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,96 @@
@@ -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); });