openvibers/openvibe.chat Commits
Chat preferences move to the OpenVibe.Network user module chat.preferences (roadmap Wave 1 item 24; Chat owns the namespace since the Wave 6 cutover). GET /api/chat/preferences and PUT /api/chat/preferences { preferences } (a patch, null removes a field, optional If-Match) read and change the signed-in person's record through Network's /internal/modules/chat.preferences/:subject with Chat's service token (audience openvibe.network, network.modules.read + write; server/prefs/network-modules.js, one retry after a 401). server/prefs/chat-preferences.js: every write names the revision it read, a revision that moved is read again and the patch re-applied (3 tries), a browser If-Match is strict (412), a patch that changes nothing writes nothing, values are checked against the namespace schema (openvibe-contracts modules.validateData) before Network sees them. Cache per person for CHAT_PREFS_TTL_MS (60 s): Chat's own writes land in it, a change made through Network directly shows within the TTL, and handleEvent() drops an entry at once for a newer network.module.updated (for when Events deliveries reach Chat); Network down serves the cached copy (stale: true) or 503. API tokens read only; no Network subject is 409 prefs.subject_unknown. scripts/migrate-chat-preferences.js (server/prefs/from-live.js) moves what Live kept in user_preferences.chat_settings: only choices (showTimestamps -> timestamps, compactMode -> compact, fontSize small/large -> font_scale 0.88/1.18, showBadges false -> show_badges), the subject from Live's linked_accounts (the network link first), Live's database opened read-only, writes through Network only and create-only (If-Match 0) so an existing record is never overwritten and a re-run creates nothing. Dry run by default; --apply requires --backup <new file>, written 0600 and read back before any write, then given each creation's revision; --rollback <file> [--apply] deletes what that run created, keeping records changed since. test/helpers.js: the stub Network serves user modules with Network's revision and If-Match rules. Tests: test/preferences.test.js, test/migrate-preferences.test.js. README (Chat preferences), STATUS.json, .env.example.
Changes
@@@ -42,6 +42,13 @@ OV_LIVE_INTERNAL_URL=http://127.0.0.1:3000LIVE_MIRROR=0#LIVE_MIRROR_INTERVAL_MS=1000++# Chat preferences in the OpenVibe.Network user module chat.preferences (grants: chat network.modules.read++# and network.modules.write on chat.preferences, audience openvibe.network). Cached per person (ms).++#CHAT_PREFS_ENABLED=1++#CHAT_PREFS_TTL_MS=60000++#CHAT_PREFS_TIMEOUT_MS=3000++#CHAT_PREFS_CACHE_MAX=5000++# OpenVibe.VIP — member badges on chat messages (grant: chat vip.entitlement.check openvibe.vip).# Cache lifetimes (ms): a badge outlives a membership by at most CHAT_VIP_BADGE_TTL_MS after VIP# stops granting; a "no" is re-asked after the deny TTL, a failure after the unavailable TTL.@@@ -64,6 +64,7 @@ before changing behaviour.** Browser JavaScript does not change; nginx routes th| Live's own chat pushes and writes (AI viewers, relays, donations, `/api/mod`, recaps, calls) | **Live → Chat** | `POST /internal/live/calls` (`chat.live_bridge.write`), presence `GET /internal/live/presence` (`chat.presence.read`) || Identity | **OpenVibe.Network** | user tokens are resolved by Live (its account links); service tokens from `/oauth/token` || Events | **OpenVibe.Events** | `events_outbox` → `POST /api/v1/events` when `EVENTS_URL` is set (`events.event.publish`) |++| A person's chat preferences | **OpenVibe.Network** user module `chat.preferences` (Chat owns the namespace) | `server/prefs/` → `GET/PUT/DELETE /internal/modules/chat.preferences/:subject` (`network.modules.read` / `.write`), cached per person || Member badges | **OpenVibe.VIP** (Billing holds the entitlement) | `server/vip/badges.js` → `POST /api/v1/entitlements/check` with `product: 'chat'` (`vip.entitlement.check`), behind the shared product cache |### Data@@@ -125,6 +126,36 @@ written by creators, so nothing else of it passes and clients render both as texnobody gets a badge. The client is vendored from OpenVibe.VIP (`server/vip/vip-client.js`, copiedat VIP 2accbeb) until VIP publishes a tag to pin.++### Chat preferences++++`GET /api/chat/preferences` and `PUT /api/chat/preferences { preferences: { … } }` (a patch; `null`++removes a field; optional `If-Match: <revision>`) read and change the signed-in person's chat++preferences. They live in OpenVibe.Network as the user module `chat.preferences` (openvibe-contracts++namespace, schema v1: `timestamps`, `compact`, `font_scale` 0.75–2, `show_badges`, `hide_emotes`), which++Chat owns since the cutover; Network validates, versions (revision, the ETag here) and announces each++change as `network.module.updated`. Chat reads and writes with its service token (Network grants `chat++network.modules.read` and `network.modules.write` on `chat.preferences`) and names the revision it read on++every write, so another writer is never overwritten (Chat reads again and re-applies the patch; a browser++If-Match is strict: 412).++++- **Cache.** One entry per person for `CHAT_PREFS_TTL_MS` (60 s). Chat's own writes land in it at once; a++ change made through Network directly (the person's account page) shows within the TTL, and at once++ when a `network.module.updated` for it reaches `prefs.handleEvent()` (for when Events deliveries reach++ Chat). Network down: the cached copy with `stale: true`, else 503; writes 503.++- API tokens read only. A person Live knows without a Network subject gets 409 `prefs.subject_unknown`.++- **Migration** of what Live kept server-side (`user_preferences.chat_settings`, the browser's whole++ `chatSettings`): `scripts/migrate-chat-preferences.js`. Only choices move (`showTimestamps` →++ `timestamps`, `compactMode` → `compact`, `fontSize` small/large → `font_scale` 0.88/1.18, `showBadges`++ false → `show_badges`); a person with defaults only gets no record, and an existing record is never++ overwritten (create-only, `If-Match: 0`), so it is safe to re-run. The rest of `chatSettings` (TTS,++ volumes, cross-feed, notifications, …) has no field in schema v1 and stays in Live for now.++++```bash++node scripts/migrate-chat-preferences.js --live-db /tmp/live-snapshot.db # dry run++node scripts/migrate-chat-preferences.js --live-db /tmp/live-snapshot.db --apply --backup /root/chat-prefs-$(date +%F).json++node scripts/migrate-chat-preferences.js --rollback /root/chat-prefs-<date>.json [--apply] # undo that run++```++## Running it```bash@@@ -156,8 +187,9 @@ server/auth/ token resolution through Live; the chat subset of Livserver/bridge/ Live → Chat calls + presence (live-bridge.js); Chat → Live read mirror (live-mirror.js)server/events/outbox.js events.event-envelope@1 outbox and relayserver/net/service-auth.js service tokens: client (Chat → others) and guard (others → Chat)++server/prefs/ chat preferences in the Network user module chat.preferences (routes, cache, migration from Live)server/db/ schema.sql, database.js (Live's chat functions, same names and arguments)--scripts/ import-from-live, parity-check, mirror-flush++scripts/ import-from-live, parity-check, mirror-flush, migrate-chat-preferencesdocs/ cutover.md, live-patch.diff, capabilities-proposal/```@@@ -195,7 +227,7 @@ Events: `chat.message.created`, `chat.message.deleted`, `chat.dm.created`, `chat## Depends on--- OpenVibe.Network++- OpenVibe.Network (identity, service tokens, the `chat.preferences` user module)- OpenVibe.Live (until the rest of chat's data moves)- OpenVibe.Events- OpenVibe.Media@@@ -9,11 +9,12 @@"domain": "openvibe.live (/ws/chat, /api/chat/, /api/dm/, /api/tts/, /api/sounds via an nginx include in openvibe.live's vhost); openvibe.chat is still the OpenVibe.Sites placeholder","contracts": "openvibe-contracts v0.13.0","capabilities": ["chat.live_bridge.write", "chat.presence.read"],-- "capabilitiesUsed": ["live.chat_context.read", "live.chat_effects.write", "live.chat_mirror.write", "events.event.publish", "vip.entitlement.check"],++ "capabilitiesUsed": ["live.chat_context.read", "live.chat_effects.write", "live.chat_mirror.write", "events.event.publish", "vip.entitlement.check", "network.modules.read", "network.modules.write"],"capabilitiesRegistered": true,"events": ["chat.message.created", "chat.message.deleted", "chat.dm.created", "chat.moderation.action"],"liveAuthorityFlag": "CHAT_AUTHORITY=chat is set in production live.env since the cutover (2026-09-23 02:03 UTC); Live's chat tables are a read mirror Chat keeps current","vip": "Member badges from OpenVibe.VIP (server/vip/badges.js: entitlements/check with product chat, cached 60 s, never blocks sending, fails closed). In code only, not deployed; needs the Network grant chat vip.entitlement.check openvibe.vip and Live's chat UI to render vip_badge / chat_vip_badge",++ "preferences": "Chat preferences in the Network user module chat.preferences (server/prefs/: GET/PUT /api/chat/preferences with Chat's service token, cached 60 s, If-Match on every write; scripts/migrate-chat-preferences.js moves the choices in Live's user_preferences). In code only, not deployed; needs Network with the chat.preferences handoff and the grants chat network.modules.read/write on chat.preferences, then the migration, then Live's chat.js calling /api/chat/preferences","deployed": true,"deployment": "openvibe-ovh /opt/openvibe.chat, unit openvibe-chat, 127.0.0.1:4400 (loopback); public only through openvibe.live's nginx include for the chat paths; release 45f2b1f; EVENTS_URL set (chat.message.created reaches OpenVibe.Events); restore drill passed 2026-09-23","updated": "2026-09-23"nnew file mode 100644@@@ -0,0 +1,82 @@++#!/usr/bin/env node++/**++ * Move the chat preferences Live kept (user_preferences.chat_settings) into the OpenVibe.Network user++ * module chat.preferences, which Chat owns. One-off and idempotent; logic in server/prefs/from-live.js.++ *++ * node scripts/migrate-chat-preferences.js --live-db <live.db> # dry run (default)++ * node scripts/migrate-chat-preferences.js --live-db <live.db> --apply --backup <file> # write++ * node scripts/migrate-chat-preferences.js --rollback <file> [--apply] # undo a run++ *++ * Options:++ * --live-db <file> Live's database (a snapshot is best: sqlite3 live.db ".backup /tmp/live.db"), opened read-only++ * --apply write; without it nothing is written and the report says what would be++ * --backup <file> required with --apply; must not exist. Written (0600) and read back before any++ * write: every record the run will create (none existed before) and its data; the++ * revision each creation got is added when the run ends. --rollback takes it.++ * --rollback <file> delete the records that run created, each only while still at the revision it++ * got (a record the person changed since is kept). Dry run unless --apply.++ *++ * Reads the same environment as the server (.env / /etc/openvibe/chat.env): OV_NETWORK_INTERNAL_URL,++ * OV_OAUTH_CLIENT_ID, OV_OAUTH_CLIENT_SECRET. Chat's principal needs network.modules.read and++ * network.modules.write on chat.preferences (Network default grants). Every write goes through Network,++ * which validates it, gives it a revision and emits network.module.updated.++ *++ * Only choices move (a value equal to Live's default is not one): showTimestamps, compactMode, fontSize,++ * showBadges. Prints a JSON report. Exit codes: 0 done (or dry run), 2 refused (bad options), 1 failed.++ */++'use strict';++++const fs = require('fs');++++function usage(msg, code = msg ? 2 : 0) {++ if (msg) console.error(msg);++ console.error('usage: node scripts/migrate-chat-preferences.js --live-db <live.db> [--apply --backup <file>]\n'++ + ' node scripts/migrate-chat-preferences.js --rollback <file> [--apply]');++ process.exit(code);++}++++function parseArgs(argv) {++ const out = { liveDb: null, apply: false, backup: null, rollback: null };++ const value = (i, name) => { const v = argv[i]; if (!v || v.startsWith('--')) usage(`${name} needs a value`); return v; };++ for (let i = 0; i < argv.length; i++) {++ const a = argv[i];++ if (a === '--live-db') out.liveDb = value(++i, a);++ else if (a === '--backup') out.backup = value(++i, a);++ else if (a === '--rollback') out.rollback = value(++i, a);++ else if (a === '--apply') out.apply = true;++ else if (a === '--dry-run') out.apply = false;++ else if (a === '-h' || a === '--help') usage();++ else usage(`unknown argument ${a}`);++ }++ if (out.rollback) {++ if (out.liveDb || out.backup) usage('--rollback takes only the backup file (and --apply)');++ if (!fs.existsSync(out.rollback)) usage(`no backup at ${out.rollback}`);++ return out;++ }++ if (!out.liveDb) usage('missing --live-db');++ if (!fs.existsSync(out.liveDb)) usage(`no Live database at ${out.liveDb}`);++ if (out.apply && !out.backup) usage('--apply needs --backup <file>: what the run creates is recorded there first');++ if (out.backup && fs.existsSync(out.backup)) usage(`${out.backup} exists already; pick a new backup file`);++ return out;++}++++async function main() {++ const args = parseArgs(process.argv.slice(2));++ const config = require('../server/config');++ if (!config.oauth.clientSecret) usage('OV_OAUTH_CLIENT_SECRET is not set (Chat\'s service principal writes the records)');++ const migration = require('../server/prefs/from-live');++ if (args.rollback) {++ const report = await migration.rollback(args.rollback, { apply: args.apply });++ console.log(JSON.stringify(report, null, 2));++ return;++ }++ const Database = require('better-sqlite3');++ const liveDb = new Database(args.liveDb, { readonly: true, fileMustExist: true });++ let planned;++ try { planned = migration.plan(liveDb); } finally { liveDb.close(); }++ const report = await migration.migrate(planned, { apply: args.apply, backup: args.backup, liveDbPath: args.liveDb, log: (m) => console.error(m) });++ console.log(JSON.stringify(report, null, 2));++ if (!args.apply) console.error('dry run: nothing written (add --apply --backup <file> to write)');++}++++main().catch((err) => { console.error(`migrate-chat-preferences failed: ${err.message}`); process.exit(1); });@@@ -176,6 +176,8 @@ function createApp({ chatServer, bridge, mirror, relay }) {next();});++ // The person's chat preferences: Network user module chat.preferences (server/prefs/).++ app.use('/api/chat/preferences', require('./prefs/routes'));app.use('/api/chat', require('./chat/routes'));app.use('/api/dm', require('./chat/dm-routes'));app.use('/api/tts', require('./chat/tts-routes'));@@@ -80,6 +80,18 @@ module.exports = {unavailableTtlMs: int(process.env.CHAT_VIP_BADGE_UNAVAILABLE_TTL_MS, 5_000),},++ // A person's chat preferences: the OpenVibe.Network user module chat.preferences, which Chat owns++ // (server/prefs/). Read and written with Chat's service token (grants network.modules.read and++ // network.modules.write on chat.preferences, audience openvibe.network). Cached per person for++ // ttlMs: Chat's own writes update the cache at once, a change made through Network directly shows++ // within ttlMs (at once when a network.module.updated event reaches prefs.handleEvent()).++ prefs: {++ enabled: bool(process.env.CHAT_PREFS_ENABLED, true),++ ttlMs: int(process.env.CHAT_PREFS_TTL_MS, 60_000),++ timeoutMs: int(process.env.CHAT_PREFS_TIMEOUT_MS, 3000),++ maxEntries: int(process.env.CHAT_PREFS_CACHE_MAX, 5000),++ },++// OpenVibe.Events — the outbox relays only when EVENTS_URL is set.events: {url: strip(process.env.EVENTS_URL || ''),nnew file mode 100644@@@ -0,0 +1,140 @@++/**++ * A person's chat preferences: the OpenVibe.Network user module `chat.preferences`++ * (openvibe-contracts manifests/namespaces/chat.preferences.json, schema v1: timestamps, compact,++ * font_scale, show_badges, hide_emotes). Chat owns the namespace since the Wave 6 cutover; Network++ * stores it, validates it, versions it (revision) and announces every change (network.module.updated).++ *++ * get(subject) the record ({ preferences, revision, version }); {} and revision 0 when the++ * person has none. Cached per person for config.prefs.ttlMs; when Network++ * cannot answer, a cached copy is served (stale: true), else 503.++ * update(subject, patch) { field: value | null } (null removes the field). Read-modify-write against++ * Network naming the revision read (If-Match); a revision that moved is read++ * again, up to 3 times. With expectedRevision (the browser's If-Match) it is++ * strict instead: 412 when the record moved. A patch that changes nothing++ * writes nothing.++ * handleEvent(envelope) network.module.updated for chat.preferences newer than the cached copy++ * drops it at once (for when Events deliveries reach Chat; until then the TTL++ * bounds how long a change made outside Chat takes to show).++ *++ * Only usr_ subjects: Network keeps modules for accounts, and Chat learns a person's subject from Live.++ */++'use strict';++++const { modules } = require('openvibe-contracts');++const config = require('../config');++const client = require('./network-modules');++++const NAMESPACE = 'chat.preferences';++const USER_SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++const FIELDS = Object.freeze(Object.keys(modules.get(NAMESPACE).schema.properties));++const MAX_TRIES = 3;++++class PrefsError extends Error {++ constructor(status, code, message, errors) { super(message); this.status = status; this.code = code; if (errors) this.errors = errors; }++}++++const cache = new Map(); // subject → { data, revision, version, at } (insertion order = age)++let clock = () => Date.now();++++function enabled() { return !!(config.prefs.enabled && config.oauth.clientSecret); }++++function remember(subject, rec) {++ cache.delete(subject);++ cache.set(subject, { data: rec.data, revision: rec.revision, version: rec.version, at: clock() });++ while (cache.size > Math.max(1, config.prefs.maxEntries)) cache.delete(cache.keys().next().value);++}++++const view = (rec, extra = {}) => ({ namespace: NAMESPACE, version: rec.version, revision: rec.revision, preferences: { ...rec.data }, ...extra });++++function unavailable(r) {++ return new PrefsError(503, 'prefs.unavailable', r.error ? `OpenVibe.Network is unavailable (${r.error})` : `OpenVibe.Network answered ${r.status}`);++}++++function checkSubject(subject) {++ if (!enabled()) throw new PrefsError(503, 'prefs.disabled', 'chat preferences are not configured on this server');++ if (!USER_SUBJECT.test(String(subject || ''))) throw new PrefsError(409, 'prefs.subject_unknown', 'this account is not linked to OpenVibe.Network yet');++}++++/** The current record from Network ({ data, revision, version }; none yet = {} at revision 0). */++async function fetchRecord(subject) {++ const r = await client.request('GET', NAMESPACE, subject);++ if (r.status === 200 && r.body && r.body.data && typeof r.body.data === 'object') {++ return { data: r.body.data, revision: Number(r.body.revision) || 0, version: Number(r.body.version) || 1 };++ }++ if (r.status === 404 && r.body && r.body.code === 'modules.not_found') return { data: {}, revision: 0, version: modules.get(NAMESPACE).version };++ if (r.status === 403) throw new PrefsError(503, 'prefs.not_granted', 'Chat may not read chat.preferences (Network grant missing)');++ throw unavailable(r);++}++++async function get(subject, { fresh = false } = {}) {++ checkSubject(subject);++ const hit = cache.get(subject);++ if (!fresh && hit && clock() - hit.at < config.prefs.ttlMs) return view(hit);++ try {++ const rec = await fetchRecord(subject);++ remember(subject, rec);++ return view(rec);++ } catch (err) {++ if (hit && err.status === 503) return view(hit, { stale: true });++ throw err;++ }++}++++/** A patch's problems: unknown fields, or a value the schema refuses once applied. */++function checkPatch(patch) {++ if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw new PrefsError(400, 'prefs.bad_request', 'send { preferences: { field: value } }');++ const unknown = Object.keys(patch).filter((k) => !FIELDS.includes(k));++ if (unknown.length) throw new PrefsError(422, 'prefs.invalid', `unknown preference${unknown.length > 1 ? 's' : ''}: ${unknown.join(', ')}`, unknown.map((k) => ({ path: `/${k}`, message: 'not a chat preference' })));++}++++const same = (a, b) => JSON.stringify(Object.entries(a).sort()) === JSON.stringify(Object.entries(b).sort());++++async function update(subject, patch, { expectedRevision } = {}) {++ checkSubject(subject);++ checkPatch(patch);++ for (let i = 0; i < MAX_TRIES; i++) {++ const cur = await fetchRecord(subject);++ if (expectedRevision !== undefined && cur.revision !== expectedRevision) {++ remember(subject, cur);++ throw new PrefsError(412, 'prefs.revision_conflict', `the preferences changed (revision ${cur.revision}, not ${expectedRevision}); read them again`);++ }++ const next = { ...cur.data };++ for (const [k, v] of Object.entries(patch)) { if (v === null) delete next[k]; else next[k] = v; }++ const v = modules.validateData(NAMESPACE, next);++ if (!v.valid) throw new PrefsError(422, 'prefs.invalid', 'these values are not allowed', v.errors);++ if (same(next, cur.data)) { remember(subject, cur); return view(cur); }++ const r = await client.request('PUT', NAMESPACE, subject, { data: next, revision: cur.revision });++ if ((r.status === 200 || r.status === 201) && r.body && r.body.data) {++ const rec = { data: r.body.data, revision: Number(r.body.revision) || 0, version: Number(r.body.version) || 1 };++ remember(subject, rec);++ return view(rec);++ }++ if (r.status === 412) {++ if (expectedRevision !== undefined) throw new PrefsError(412, 'prefs.revision_conflict', 'the preferences changed; read them again');++ continue; // someone else wrote first: read again++ }++ if (r.status === 422) throw new PrefsError(422, 'prefs.invalid', 'these values are not allowed', r.body && r.body.errors);++ if (r.status === 404) throw new PrefsError(409, 'prefs.subject_unknown', 'this account is not known to OpenVibe.Network');++ if (r.status === 403 || r.status === 409) throw new PrefsError(503, 'prefs.not_granted', `Network refused the write (${(r.body && r.body.code) || r.status})`);++ throw unavailable(r);++ }++ throw new PrefsError(409, 'prefs.busy', 'the preferences kept changing; try again');++}++++function handleEvent(envelope) {++ if (!envelope || envelope.event_type !== 'network.module.updated' || envelope.source !== 'network') return false;++ const p = envelope.payload && typeof envelope.payload === 'object' ? envelope.payload : {};++ const subject = p.owner && p.owner.id;++ if (p.namespace !== NAMESPACE || !cache.has(subject)) return false;++ if (!(Number(p.revision) > cache.get(subject).revision)) return false; // ours, or older than what we hold++ cache.delete(subject);++ return true;++}++++function invalidate(subject) { return cache.delete(subject); }++++/** Tests: an injected clock; clears the cache. */++function _configure({ now } = {}) { if (now) clock = now; cache.clear(); }++function _cached(subject) { return cache.get(subject) || null; }++++module.exports = { NAMESPACE, FIELDS, PrefsError, enabled, get, update, handleEvent, invalidate, _configure, _cached };nnew file mode 100644@@@ -0,0 +1,152 @@++/**++ * One-off migration of the chat preferences Live kept server-side into the Network user module++ * chat.preferences (scripts/migrate-chat-preferences.js is the command line).++ *++ * Source: Live's user_preferences.chat_settings — the browser's whole chatSettings object, synced by++ * public/js/chat.js through PUT /api/auth/preferences — and the person's Network subject from Live's++ * linked_accounts (as scripts/import-from-live.js does). Live's database is opened read-only.++ *++ * What moves (chat.preferences v1 has five fields; the rest of chatSettings has no field yet):++ * showTimestamps true → timestamps: true++ * compactMode true → compact: true++ * fontSize small | large → font_scale 0.88 | 1.18 (Live's .chat-msg 0.75rem / 1rem over its 0.85rem)++ * showBadges false → show_badges: false++ * Only choices move: a value equal to Live's default (CHAT_SETTINGS_DEFAULTS) is not written, and a++ * person with nothing but defaults gets no record.++ *++ * Writes go through Network with Chat's service token (never into Network's database), create-only++ * (If-Match: 0): a person who already has a record keeps it, so a second run changes nothing. Before++ * writing, the backup file records what each target looked like (always "no record" for what gets++ * created) and what will be written; after, the revision each creation got. rollback() deletes the++ * records a run created, only while each is still at that revision (If-Match).++ */++'use strict';++++const fs = require('fs');++const path = require('path');++const { modules } = require('openvibe-contracts');++const client = require('./network-modules');++++const NAMESPACE = 'chat.preferences';++const FONT_SCALE = Object.freeze({ small: 0.88, large: 1.18 });++const USER_SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++++/** Live's chatSettings → chat.preferences data (choices only). */++function fromLiveChatSettings(settings) {++ const s = settings && typeof settings === 'object' && !Array.isArray(settings) ? settings : {};++ const out = {};++ if (s.showTimestamps === true) out.timestamps = true;++ if (s.compactMode === true) out.compact = true;++ if (Object.prototype.hasOwnProperty.call(FONT_SCALE, s.fontSize)) out.font_scale = FONT_SCALE[s.fontSize];++ if (s.showBadges === false) out.show_badges = false;++ return out;++}++++/** Read Live's rows and decide what each becomes. Returns { rows: [...], counts }. No network. */++function plan(liveDb) {++ const has = (t) => !!liveDb.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(t);++ if (!has('user_preferences')) throw new Error('Live database has no user_preferences table');++ const laCols = has('linked_accounts') ? liveDb.prepare('PRAGMA table_info(linked_accounts)').all().map((c) => c.name) : [];++ const subjectSql = laCols.includes('subject_id')++ ? `(SELECT la.subject_id FROM linked_accounts la WHERE la.user_id = up.user_id AND la.subject_id IS NOT NULL++ ORDER BY (la.service = 'network') DESC, la.id LIMIT 1)`++ : 'NULL';++ const rows = liveDb.prepare(`SELECT up.user_id, up.chat_settings, up.updated_at, ${subjectSql} AS subject_id++ FROM user_preferences up ORDER BY up.user_id`).all();++ const counts = { live_rows: rows.length, unreadable: 0, no_subject: 0, defaults_only: 0, invalid: 0, candidates: 0, fields: {} };++ const out = [];++ for (const r of rows) {++ let settings;++ try { settings = JSON.parse(r.chat_settings || '{}'); } catch { counts.unreadable++; continue; }++ if (!USER_SUBJECT.test(String(r.subject_id || ''))) { counts.no_subject++; continue; }++ const data = fromLiveChatSettings(settings);++ if (!Object.keys(data).length) { counts.defaults_only++; continue; }++ if (!modules.validateData(NAMESPACE, data).valid) { counts.invalid++; continue; }++ for (const k of Object.keys(data)) counts.fields[k] = (counts.fields[k] || 0) + 1;++ counts.candidates++;++ out.push({ live_user_id: r.user_id, subject: r.subject_id, live_updated_at: r.updated_at || null, data });++ }++ return { rows: out, counts };++}++++/** The current record of each candidate on Network: null (none), the record, or throws when unreachable. */++async function current(subject) {++ const r = await client.request('GET', NAMESPACE, subject);++ if (r.status === 200 && r.body) return { revision: r.body.revision, data: r.body.data, updated_by: r.body.updated_by || null };++ if (r.status === 404 && r.body && r.body.code === 'modules.not_found') return null;++ throw new Error(`reading ${subject} on Network: ${r.status}${r.error ? ` ${r.error}` : ''}${r.body && r.body.code ? ` ${r.body.code}` : ''}`);++}++++function writeJsonAtomic(file, value) {++ const tmp = `${file}.tmp-${process.pid}`;++ fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n', { mode: 0o600, flag: 'wx' });++ fs.renameSync(tmp, file);++}++++/**++ * Check every candidate against Network; with apply, write the backup first, then create the records.++ * Returns the report. `backup` must name a file that does not exist yet (required with apply).++ */++async function migrate(planned, { apply = false, backup = null, liveDbPath = null, log = () => {} } = {}) {++ if (apply && !backup) throw new Error('--apply needs --backup <file>');++ const report = { mode: apply ? 'apply' : 'dry-run', ...planned.counts, exists: 0, would_create: 0, created: 0, raced: 0, refused: 0 };++ const targets = [];++ for (const row of planned.rows) {++ const before = await current(row.subject);++ if (before) { report.exists++; continue; }++ targets.push({ ...row, before: null });++ }++ report.would_create = targets.length;++ if (!apply) return report;++++ const file = path.resolve(backup);++ if (fs.existsSync(file)) throw new Error(`${file} exists already; pick a new backup file`);++ const doc = { kind: 'openvibe-chat/chat-preferences-migration', version: 1, namespace: NAMESPACE, created_at: new Date().toISOString(),++ live_db: liveDbPath, targets: targets.map((t) => ({ live_user_id: t.live_user_id, subject: t.subject, before: null, data: t.data })), results: null };++ writeJsonAtomic(file, doc);++ const check = JSON.parse(fs.readFileSync(file, 'utf8'));++ if (!check || !Array.isArray(check.targets) || check.targets.length !== targets.length) throw new Error(`backup ${file} did not read back; nothing written`);++ log(`backup written: ${file} (${targets.length} target(s))`);++++ const results = [];++ for (const t of targets) {++ const r = await client.request('PUT', NAMESPACE, t.subject, { data: t.data, revision: 0 });++ if (r.status === 201 && r.body) { report.created++; results.push({ subject: t.subject, outcome: 'created', revision: r.body.revision }); }++ else if (r.status === 412) { report.raced++; results.push({ subject: t.subject, outcome: 'exists' }); }++ else if (r.status === 0) {++ doc.results = results; writeJsonAtomic(file, doc);++ throw new Error(`Network became unreachable after ${results.length} write(s): ${r.error}; the backup lists what was done, re-run to continue`);++ } else { report.refused++; results.push({ subject: t.subject, outcome: 'refused', status: r.status, code: (r.body && r.body.code) || null }); }++ }++ doc.results = results;++ doc.finished_at = new Date().toISOString();++ writeJsonAtomic(file, doc);++ return report;++}++++/**++ * Undo a run from its backup: delete each record it created, only while still at the revision it got++ * (for a run that stopped before recording results: only while the data is exactly what it wrote).++ */++async function rollback(file, { apply = false } = {}) {++ const doc = JSON.parse(fs.readFileSync(file, 'utf8'));++ if (!doc || doc.kind !== 'openvibe-chat/chat-preferences-migration' || !Array.isArray(doc.targets)) throw new Error(`${file} is not a chat-preferences migration backup`);++ const byResult = new Map((doc.results || []).map((r) => [r.subject, r]));++ const report = { mode: apply ? 'apply' : 'dry-run', targets: doc.targets.length, deleted: 0, would_delete: 0, gone: 0, changed_since: 0, not_created: 0 };++ for (const t of doc.targets) {++ const res = byResult.get(t.subject);++ if (res && res.outcome !== 'created') { report.not_created++; continue; }++ const cur = await current(t.subject);++ if (!cur) { report.gone++; continue; }++ const ours = res ? cur.revision === res.revision : JSON.stringify(cur.data) === JSON.stringify(t.data) && cur.updated_by === 'svc:chat';++ if (!ours) { report.changed_since++; continue; }++ if (!apply) { report.would_delete++; continue; }++ const r = await client.request('DELETE', NAMESPACE, t.subject, { revision: cur.revision });++ if (r.status === 204) report.deleted++;++ else if (r.status === 412 || r.status === 404) report.changed_since++;++ else throw new Error(`deleting ${t.subject}: Network answered ${r.status}${r.error ? ` ${r.error}` : ''}`);++ }++ return report;++}++++module.exports = { NAMESPACE, FONT_SCALE, fromLiveChatSettings, plan, migrate, rollback };nnew file mode 100644@@@ -0,0 +1,41 @@++/**++ * Chat's client for OpenVibe.Network user modules (Network server/identity/modules.js, service routes):++ *++ * GET /internal/modules/:ns/:subject network.modules.read++ * PUT /internal/modules/:ns/:subject If-Match: <rev> network.modules.write (owner service only)++ * DELETE /internal/modules/:ns/:subject If-Match: <rev> network.modules.write (owner service only)++ *++ * with Chat's service token for audience openvibe.network (server/net/service-auth.js). A 401 drops the++ * cached token and tries once more. Network down, slow or without a token for us → status 0 with++ * `error`; every other answer is returned as { status, body } for the caller to judge.++ */++'use strict';++++const config = require('../config');++const serviceAuth = require('../net/service-auth');++++const AUDIENCE = 'openvibe.network';++const SCOPE = 'network.modules.read network.modules.write';++++async function request(method, namespace, subject, { data, revision } = {}) {++ const url = `${config.networkInternalUrl}/internal/modules/${encodeURIComponent(namespace)}/${encodeURIComponent(subject)}`;++ for (let attempt = 0; attempt < 2; attempt++) {++ let auth;++ try { auth = await serviceAuth.headers(AUDIENCE, SCOPE); } catch (err) { return { status: 0, error: `no service token: ${err.message}` }; }++ const headers = { ...auth, Accept: 'application/json' };++ if (data !== undefined) headers['Content-Type'] = 'application/json';++ if (revision !== undefined && revision !== null) headers['If-Match'] = `"${Number(revision)}"`;++ let res;++ try {++ res = await fetch(url, { method, headers, body: data !== undefined ? JSON.stringify({ data }) : undefined, signal: AbortSignal.timeout(config.prefs.timeoutMs) });++ } catch (err) {++ return { status: 0, error: `Network unreachable: ${err.message}` };++ }++ if (res.status === 401 && attempt === 0) { serviceAuth.invalidate(AUDIENCE, SCOPE); continue; }++ const body = res.status === 204 ? null : await res.json().catch(() => null);++ return { status: res.status, body };++ }++ return { status: 401, body: null };++}++++module.exports = { AUDIENCE, SCOPE, request };nnew file mode 100644@@@ -0,0 +1,44 @@++/**++ * The signed-in person's chat preferences (Network user module chat.preferences, ./chat-preferences.js).++ * Mounted at /api/chat/preferences (nginx already sends /api/chat/ to Chat).++ *++ * GET /api/chat/preferences → { namespace, version, revision, preferences, stale? }++ * PUT /api/chat/preferences { preferences } change these fields (null removes one); optional++ * If-Match: <revision> makes it fail with 412 if they moved++ *++ * Field names are the namespace's (timestamps, compact, font_scale, show_badges, hide_emotes). ETag is++ * the revision. API tokens (hbt_) may read, never write: preferences are the person's own.++ */++'use strict';++++const express = require('express');++const { requireAuth } = require('../auth/auth');++const prefs = require('./chat-preferences');++++const router = express.Router();++++function fail(res, err) {++ if (err instanceof prefs.PrefsError) {++ return res.status(err.status).json({ error: err.message, code: err.code, ...(err.errors ? { errors: err.errors } : {}) });++ }++ console.error('[Prefs] unexpected error:', err && err.message);++ return res.status(500).json({ error: 'Internal server error' });++}++++const reply = (res, out) => res.set('Cache-Control', 'private, no-store').set('ETag', `"${out.revision}"`).json(out);++++router.get('/', requireAuth, async (req, res) => {++ try { reply(res, await prefs.get(req.user.subject_id)); } catch (err) { fail(res, err); }++});++++router.put('/', requireAuth, async (req, res) => {++ if (req.authSource === 'api_token') return res.status(403).json({ error: 'API tokens cannot change chat preferences', code: 'prefs.token_denied' });++ let expectedRevision;++ if (req.headers['if-match'] !== undefined) {++ expectedRevision = Number(String(req.headers['if-match']).replace(/^W\//, '').replace(/"/g, ''));++ if (!Number.isInteger(expectedRevision) || expectedRevision < 0) return res.status(400).json({ error: 'If-Match must be a revision number', code: 'prefs.bad_revision' });++ }++ try { reply(res, await prefs.update(req.user.subject_id, req.body && req.body.preferences, { expectedRevision })); } catch (err) { fail(res, err); }++});++++module.exports = router;@@@ -9,6 +9,7 @@* const ws = await h.ws({ ip, token, stream }); ws.next(pred), ws.send(obj), ws.all* await h.http('GET', '/api/chat/…', { token })* h.userToken(userId), h.serviceToken(caps, { aud })++ * h.netModules — the stub Network's user modules (records, subjects, calls, down, beforeWrite)*/const fs = require('fs');const os = require('os');@@@ -46,9 +47,59 @@ async function boot({ env = {} } = {}) {// ── Stub Network ──const tokenRequests = [];++ // User modules (Network server/identity/modules.js semantics): records per (namespace, subject) with++ // revisions that never go back, If-Match, schema/quota checks, service tokens for openvibe.network.++ // subjects = the usr_ ids Network knows; beforeWrite(req) may change a record to stage a race.++ const { modules: nsLib } = require('openvibe-contracts');++ const netModules = { records: new Map(), revisions: new Map(), subjects: new Set(), calls: [], down: false, beforeWrite: null };++ netModules.key = (ns, subject) => `${ns}|${subject}`;++ netModules.set = (ns, subject, data, by = 'user:' + subject) => {++ const k = netModules.key(ns, subject);++ const cur = netModules.records.get(k);++ const revision = Math.max(cur ? cur.revision : 0, netModules.revisions.get(k) || 0) + 1;++ netModules.revisions.set(k, revision);++ netModules.records.set(k, { data, revision, version: nsLib.get(ns).version, updated_by: by, updated_at: sqliteNow() });++ return revision;++ };++ const moduleRecord = (ns, subject, r) => ({ subject: { type: 'user', id: subject }, namespace: ns, version: r.version, revision: r.revision, data: r.data, updated_at: r.updated_at, updated_by: r.updated_by });++ function modulesRoute(req, res, raw, ns, subject) {++ const send = (status, body) => { res.statusCode = status; res.end(body === null ? undefined : JSON.stringify(body)); };++ netModules.calls.push({ method: req.method, ns, subject, ifMatch: req.headers['if-match'] });++ if (netModules.down) { req.socket.destroy(); return; }++ const auth = String(req.headers.authorization || '');++ const v = auth.startsWith('Bearer ') ? serviceAuth.verifyServiceToken(auth.slice(7), { publicKey: keys.publicKey, issuer: ISS, audience: 'openvibe.network' }) : { ok: false, code: 'token.missing' };++ if (!v.ok) return send(401, { code: v.code });++ if (!(v.claims.cap || []).includes(req.method === 'GET' ? 'network.modules.read' : 'network.modules.write')) return send(403, { code: 'capability.denied' });++ const def = nsLib.get(ns);++ if (!def) return send(404, { code: 'modules.unknown_namespace' });++ const k = netModules.key(ns, subject);++ const known = netModules.subjects.has(subject);++ if (req.method === 'GET') {++ const r = known && netModules.records.get(k);++ return r ? send(200, moduleRecord(ns, subject, r)) : send(404, { code: 'modules.not_found' });++ }++ if (!known) return send(404, { code: 'identity.subject_not_found' });++ if (netModules.beforeWrite) { const f = netModules.beforeWrite; netModules.beforeWrite = null; f(req); }++ const cur = netModules.records.get(k);++ const have = cur ? cur.revision : 0;++ if (req.headers['if-match'] !== undefined && Number(String(req.headers['if-match']).replace(/"/g, '')) !== have) return send(412, { code: 'modules.revision_conflict' });++ if (req.method === 'DELETE') {++ if (!cur) return send(404, { code: 'modules.not_found' });++ netModules.records.delete(k);++ netModules.revisions.set(k, Math.max(have, netModules.revisions.get(k) || 0) + 1);++ return send(204, null);++ }++ let data; try { data = JSON.parse(raw).data; } catch { data = undefined; }++ const val = nsLib.validateData(ns, data);++ if (!val.valid) return send(422, { code: 'modules.invalid_data', errors: val.errors });++ netModules.set(ns, subject, data, `svc:${String(v.claims.sub).replace(/^svc:/, '')}`);++ return send(cur ? 200 : 201, moduleRecord(ns, subject, netModules.records.get(k)));++ }const network = http.createServer(async (req, res) => {const raw = await new Promise((r) => { let s = ''; req.on('data', (c) => { s += c; }); req.on('end', () => r(s)); });res.setHeader('Content-Type', 'application/json');++ const mm = /^\/internal\/modules\/([^/?]+)\/([^/?]+)$/.exec(req.url);++ if (mm) return modulesRoute(req, res, raw, decodeURIComponent(mm[1]), decodeURIComponent(mm[2]));if (req.url === '/oauth/token') {const f = new URLSearchParams(raw);tokenRequests.push({ audience: f.get('audience'), scope: f.get('scope'), client: f.get('client_id') });@@@ -239,7 +290,7 @@ async function boot({ env = {} } = {}) {});const h = {-- tmp, keys, live, network, liveServer, tokenRequests, serviceToken, ISS,++ tmp, keys, live, network, liveServer, tokenRequests, serviceToken, ISS, netModules,userSeq: 0,/** Add a Live user (projection row) with a browser token; returns { id, token, ...row }. */addUser(username, { role = 'user', subject = null, created_at = sqliteNow(-30 * 86400e3), apiScopes = null, is_owner = 0, display_name = null } = {}) {nnew file mode 100644@@@ -0,0 +1,159 @@++'use strict';++/**++ * scripts/migrate-chat-preferences.js (server/prefs/from-live.js): Live's user_preferences.chat_settings++ * → the Network user module chat.preferences, through Network with Chat's service token.++ *++ * - mapping: only choices move (showTimestamps, compactMode, fontSize, showBadges); defaults-only rows,++ * rows without a Network subject and unreadable rows are counted and skipped++ * - dry run by default: reads Network, writes nothing++ * - --apply requires --backup (a new file); the backup is written, 0600, before any write and gets++ * each creation's revision; writes are create-only (If-Match: 0), so a second run creates nothing++ * - --rollback deletes what the run created, keeping records changed since++ * - the command line: refusals exit 2, a dry run through the real script exits 0++ */++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const { spawn } = require('child_process');++const Database = require('better-sqlite3');++const { boot, suite } = require('./helpers');++++const t = suite('migrate-preferences');++const NS = 'chat.preferences';++const S = {++ ann: 'usr_01J9ANN0000000000000000AAA', eve: 'usr_01J9EVE0000000000000000AAA', fay: 'usr_01J9FAY0000000000000000AAA',++ gus: 'usr_01J9GVS0000000000000000AAA', gusOld: 'usr_01J9GVS0000000000000000ZZZ', bob: 'usr_01J9B0B0000000000000000AAA',++};++const SCRIPT = path.join(__dirname, '..', 'scripts', 'migrate-chat-preferences.js');++let h, mig, livePath;++const liveDefaults = { showTimestamps: false, timestampFormat: '12h', fontSize: 'default', showAvatars: true, showBadges: true, compactMode: false, ttsEnabled: true, ttsVolume: 80, ttsSendModeV2: true };++const record = (subject) => h.netModules.records.get(`${NS}|${subject}`) || null;++const puts = () => h.netModules.calls.filter((c) => c.method === 'PUT');++// Async: the stub Network answers from this process, so it must keep running while the script does.++const run = (args) => new Promise((resolve) => {++ const child = spawn(process.execPath, [SCRIPT, ...args], { cwd: path.join(__dirname, '..'), env: process.env });++ let stdout = ''; let stderr = '';++ child.stdout.on('data', (c) => { stdout += c; });++ child.stderr.on('data', (c) => { stderr += c; });++ child.on('close', (status) => resolve({ status, stdout, stderr }));++});++++t('a Live database with every kind of row', async () => {++ h = await boot();++ mig = require('../server/prefs/from-live');++ livePath = path.join(h.tmp, 'live.db');++ const live = new Database(livePath);++ live.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT);++ CREATE TABLE linked_accounts (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, service TEXT, service_user_id TEXT, subject_id TEXT);++ CREATE TABLE user_preferences (user_id INTEGER PRIMARY KEY, chat_settings TEXT DEFAULT '{}', updated_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);++ const add = (id, name, settings, links) => {++ live.prepare('INSERT INTO users (id, username) VALUES (?, ?)').run(id, name);++ for (const [service, subject] of links) live.prepare('INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (?, ?, ?, ?)').run(id, service, `x${id}`, subject);++ live.prepare('INSERT INTO user_preferences (user_id, chat_settings) VALUES (?, ?)').run(id, typeof settings === 'string' ? settings : JSON.stringify(settings));++ };++ add(1, 'ann', { ...liveDefaults, showTimestamps: true, fontSize: 'large', showBadges: false }, [['network', S.ann]]);++ add(2, 'bob', liveDefaults, [['network', S.bob]]); // defaults only++ add(3, 'cat', { ...liveDefaults, compactMode: true }, []); // no subject++ add(4, 'dan', '{not json', [['network', 'usr_01J9DAN0000000000000000AAA']]); // unreadable++ add(5, 'eve', { ...liveDefaults, compactMode: true, fontSize: 'small' }, [['network', S.eve]]); // has a record already++ add(6, 'fay', { compactMode: true }, [['network', S.fay]]); // a partial old blob++ add(7, 'gus', { ...liveDefaults, fontSize: 'small' }, [['hobostreamer', S.gusOld], ['network', S.gus]]); // the network link wins++ live.close();++ for (const s of Object.values(S)) h.netModules.subjects.add(s);++ h.netModules.set(NS, S.eve, { hide_emotes: true });++});++++t('the mapping keeps choices only', () => {++ assert.deepStrictEqual(mig.fromLiveChatSettings({ ...liveDefaults, showTimestamps: true, fontSize: 'large', showBadges: false, compactMode: true }),++ { timestamps: true, compact: true, font_scale: 1.18, show_badges: false });++ assert.deepStrictEqual(mig.fromLiveChatSettings(liveDefaults), {});++ assert.deepStrictEqual(mig.fromLiveChatSettings({ fontSize: 'small', showTimestamps: 'yes' }), { font_scale: 0.88 });++ assert.deepStrictEqual(mig.fromLiveChatSettings(null), {});++});++++t('plan: counts and targets from Live, read-only', () => {++ const live = new Database(livePath, { readonly: true });++ const p = mig.plan(live);++ live.close();++ assert.deepStrictEqual(p.counts, { live_rows: 7, unreadable: 1, no_subject: 1, defaults_only: 1, invalid: 0, candidates: 4, fields: { timestamps: 1, font_scale: 3, show_badges: 1, compact: 2 } });++ assert.deepStrictEqual(p.rows.map((r) => [r.live_user_id, r.subject]), [[1, S.ann], [5, S.eve], [6, S.fay], [7, S.gus]]);++ assert.deepStrictEqual(p.rows[0].data, { timestamps: true, font_scale: 1.18, show_badges: false });++});++++t('the command line refuses unsafe runs (exit 2) and nothing is written', async () => {++ let r = await run(['--live-db', livePath, '--apply']);++ assert.strictEqual(r.status, 2, r.stderr); assert.ok(r.stderr.includes('--backup'));++ const existing = path.join(h.tmp, 'exists.json');++ fs.writeFileSync(existing, '{}');++ r = await run(['--live-db', livePath, '--apply', '--backup', existing]);++ assert.strictEqual(r.status, 2); assert.ok(r.stderr.includes('exists already'));++ r = await run([]);++ assert.strictEqual(r.status, 2);++ r = await run(['--live-db', path.join(h.tmp, 'nope.db')]);++ assert.strictEqual(r.status, 2);++ assert.strictEqual(puts().length, 0);++});++++t('dry run through the real script: a report, reads only', async () => {++ const r = await run(['--live-db', livePath]);++ assert.strictEqual(r.status, 0, r.stderr);++ const report = JSON.parse(r.stdout);++ assert.strictEqual(report.mode, 'dry-run');++ assert.strictEqual(report.exists, 1, 'eve has one');++ assert.strictEqual(report.would_create, 3);++ assert.strictEqual(puts().length, 0);++ assert.ok(h.tokenRequests.length, 'the script asked Network for a token as chat');++});++++t('--apply --backup: backup first (0600), create-only writes, revisions recorded', async () => {++ const backup = path.join(h.tmp, 'prefs-backup-1.json');++ const live = new Database(livePath, { readonly: true });++ const planned = mig.plan(live);++ live.close();++ await assert.rejects(mig.migrate(planned, { apply: true }), /--backup/);++ h.netModules.beforeWrite = () => {++ const doc = JSON.parse(fs.readFileSync(backup, 'utf8'));++ assert.strictEqual(doc.targets.length, 3, 'the backup exists before the first write');++ assert.strictEqual(doc.results, null);++ };++ const report = await mig.migrate(planned, { apply: true, backup, liveDbPath: livePath });++ assert.deepStrictEqual([report.created, report.exists, report.raced, report.refused], [3, 1, 0, 0]);++ assert.ok(puts().every((c) => c.ifMatch === '"0"'), 'create-only');++ assert.deepStrictEqual(record(S.ann).data, { timestamps: true, font_scale: 1.18, show_badges: false });++ assert.strictEqual(record(S.ann).updated_by, 'svc:chat');++ assert.deepStrictEqual(record(S.gus).data, { font_scale: 0.88 });++ assert.deepStrictEqual(record(S.eve).data, { hide_emotes: true }, 'an existing record is never overwritten');++ assert.strictEqual(record(S.gusOld), null, 'the network link is the subject');++ assert.strictEqual(fs.statSync(backup).mode & 0o777, 0o600);++ const doc = JSON.parse(fs.readFileSync(backup, 'utf8'));++ assert.deepStrictEqual(doc.targets.map((x) => [x.subject, x.before]), [[S.ann, null], [S.fay, null], [S.gus, null]]);++ assert.deepStrictEqual(doc.results.map((x) => [x.subject, x.outcome, x.revision]), [[S.ann, 'created', 1], [S.fay, 'created', 1], [S.gus, 'created', 1]]);++});++++t('a second run creates nothing', async () => {++ const n = puts().length;++ const r = await run(['--live-db', livePath, '--apply', '--backup', path.join(h.tmp, 'prefs-backup-2.json')]);++ assert.strictEqual(r.status, 0, r.stderr);++ const report = JSON.parse(r.stdout);++ assert.deepStrictEqual([report.created, report.exists, report.would_create], [0, 4, 0]);++ assert.strictEqual(puts().length, n);++});++++t('--rollback deletes what the run created, keeps what changed since', async () => {++ h.netModules.set(NS, S.fay, { compact: false }); // fay changed hers after the migration++ const backup = path.join(h.tmp, 'prefs-backup-1.json');++ let r = await run(['--rollback', backup]);++ assert.strictEqual(r.status, 0, r.stderr);++ assert.deepStrictEqual((({ would_delete, changed_since, deleted }) => ({ would_delete, changed_since, deleted }))(JSON.parse(r.stdout)), { would_delete: 2, changed_since: 1, deleted: 0 });++ assert.ok(record(S.ann), 'dry run');++ r = await run(['--rollback', backup, '--apply']);++ assert.strictEqual(r.status, 0, r.stderr);++ const report = JSON.parse(r.stdout);++ assert.deepStrictEqual([report.deleted, report.changed_since, report.gone], [2, 1, 0]);++ assert.strictEqual(record(S.ann), null); assert.strictEqual(record(S.gus), null);++ assert.deepStrictEqual(record(S.fay).data, { compact: false });++ assert.deepStrictEqual(record(S.eve).data, { hide_emotes: true }, 'never the migration\'s');++ assert.ok(h.netModules.calls.filter((c) => c.method === 'DELETE').every((c) => /^"\d+"$/.test(c.ifMatch)), 'deletes name the revision');++});++++t.run(async () => { if (h) await h.close(); });nnew file mode 100644@@@ -0,0 +1,163 @@++'use strict';++/**++ * Chat preferences in the Network user module chat.preferences (server/prefs/, roadmap Wave 1 item 24):++ *++ * - GET/PUT /api/chat/preferences for the signed-in person, read and written on Network with Chat's++ * service token (audience openvibe.network, network.modules.read + write), If-Match on every write++ * - the cache: Chat's writes land in it, reads within CHAT_PREFS_TTL_MS never reach Network, a change++ * made elsewhere shows after the TTL, or at once through handleEvent(network.module.updated)++ * - a race with another writer is read again and merged; a browser If-Match is strict (412)++ * - schema refusals (422), no subject (409), API tokens read-only, Network down (stale copy or 503)++ *++ * The stub Network (test/helpers.js) keeps records with Network's revision and If-Match rules.++ */++const assert = require('assert');++const { boot, suite } = require('./helpers');++++const t = suite('preferences');++const ANN = 'usr_01J9ANN0000000000000000AAA';++const BOB = 'usr_01J9B0B0000000000000000AAA';++const NS = 'chat.preferences';++let h, prefs, ann, bob, nosub, bot, outsider;++const clock = { t: Date.now(), now() { return clock.t; } };++const calls = (method) => h.netModules.calls.filter((c) => c.method === method).length;++const record = (subject) => h.netModules.records.get(`${NS}|${subject}`) || null;++++t('boot with a stub Network that knows ann and bob', async () => {++ h = await boot({ env: { CHAT_PREFS_TTL_MS: '60000' } });++ prefs = require('../server/prefs/chat-preferences');++ prefs._configure({ now: () => clock.now() });++ ann = h.addUser('ann', { subject: ANN });++ bob = h.addUser('bob', { subject: BOB });++ nosub = h.addUser('nosub');++ bot = h.addUser('botty', { subject: 'usr_01J9B0TTY000000000000000AA', apiScopes: ['chat'] });++ outsider = h.addUser('outsider', { subject: 'usr_01J9XTRANGER0000000000000A' });++ h.netModules.subjects.add(ANN).add(BOB).add('usr_01J9B0TTY000000000000000AA');++});++++t('reading needs a signed-in person; nothing stored yet is {} at revision 0', async () => {++ let r = await h.http('GET', '/api/chat/preferences');++ assert.strictEqual(r.status, 401);++ r = await h.http('GET', '/api/chat/preferences', { token: ann.token });++ assert.strictEqual(r.status, 200, r.text);++ assert.deepStrictEqual(r.body, { namespace: NS, version: 1, revision: 0, preferences: {} });++ assert.strictEqual(r.headers.get('etag'), '"0"');++ assert.ok(r.headers.get('cache-control').includes('no-store'));++ const tok = h.tokenRequests.find((x) => x.audience === 'openvibe.network');++ assert.ok(tok, 'a token for Network was asked for');++ assert.strictEqual(tok.scope, 'network.modules.read network.modules.write');++ assert.strictEqual(tok.client, 'chat');++});++++t('a write goes to Network with If-Match and lands in the cache', async () => {++ const r = await h.http('PUT', '/api/chat/preferences', { token: ann.token, body: { preferences: { timestamps: true, font_scale: 1.18 } } });++ assert.strictEqual(r.status, 200, r.text);++ assert.deepStrictEqual(r.body.preferences, { timestamps: true, font_scale: 1.18 });++ assert.strictEqual(r.body.revision, 1);++ const put = h.netModules.calls.filter((c) => c.method === 'PUT').at(-1);++ assert.strictEqual(put.ifMatch, '"0"', 'names the revision it read');++ assert.deepStrictEqual(record(ANN).data, { timestamps: true, font_scale: 1.18 });++ assert.strictEqual(record(ANN).updated_by, 'svc:chat');++ const gets = calls('GET');++ const again = await h.http('GET', '/api/chat/preferences', { token: ann.token });++ assert.deepStrictEqual(again.body.preferences, { timestamps: true, font_scale: 1.18 });++ assert.strictEqual(calls('GET'), gets, 'served from the cache');++});++++t('a patch changes only its fields; null removes one; a no-op writes nothing', async () => {++ let r = await h.http('PUT', '/api/chat/preferences', { token: ann.token, body: { preferences: { compact: true, timestamps: null } } });++ assert.strictEqual(r.status, 200, r.text);++ assert.deepStrictEqual(r.body.preferences, { font_scale: 1.18, compact: true });++ assert.strictEqual(r.body.revision, 2);++ const puts = calls('PUT');++ r = await h.http('PUT', '/api/chat/preferences', { token: ann.token, body: { preferences: { compact: true } } });++ assert.strictEqual(r.status, 200); assert.strictEqual(r.body.revision, 2);++ assert.strictEqual(calls('PUT'), puts, 'nothing changed, nothing written');++});++++t('the namespace schema is enforced', async () => {++ let r = await h.http('PUT', '/api/chat/preferences', { token: ann.token, body: { preferences: { font_scale: 5 } } });++ assert.strictEqual(r.status, 422); assert.strictEqual(r.body.code, 'prefs.invalid'); assert.ok(r.body.errors.length);++ r = await h.http('PUT', '/api/chat/preferences', { token: ann.token, body: { preferences: { ttsVolume: 80 } } });++ assert.strictEqual(r.status, 422); assert.ok(r.body.error.includes('ttsVolume'));++ r = await h.http('PUT', '/api/chat/preferences', { token: ann.token, body: { preferences: [true] } });++ assert.strictEqual(r.status, 400);++ r = await h.http('PUT', '/api/chat/preferences', { token: ann.token, body: {} });++ assert.strictEqual(r.status, 400);++ assert.deepStrictEqual(record(ANN).data, { font_scale: 1.18, compact: true }, 'nothing written');++});++++t('a change made outside Chat shows after the TTL', async () => {++ h.netModules.set(NS, ANN, { compact: false }); // e.g. the person on my.openvibe.network++ let r = await h.http('GET', '/api/chat/preferences', { token: ann.token });++ assert.deepStrictEqual(r.body.preferences, { font_scale: 1.18, compact: true }, 'still the cached copy');++ clock.t += 60_001;++ r = await h.http('GET', '/api/chat/preferences', { token: ann.token });++ assert.deepStrictEqual(r.body.preferences, { compact: false });++ assert.strictEqual(r.body.revision, 3);++});++++t('network.module.updated newer than the cached copy drops it at once', async () => {++ const rev = h.netModules.set(NS, ANN, { hide_emotes: true });++ const ev = (revision, over = {}) => ({ event_type: 'network.module.updated', source: 'network', payload: { owner: { type: 'user', id: ANN }, namespace: NS, revision, change: 'updated', keys: ['hide_emotes'], ...over } });++ assert.strictEqual(prefs.handleEvent(ev(3)), false, 'not newer than what the cache holds');++ assert.strictEqual(prefs.handleEvent(ev(rev, { namespace: 'chat.tts_defaults' })), false, 'another namespace');++ assert.strictEqual(prefs.handleEvent({ ...ev(rev), source: 'live' }), false, 'only Network announces modules');++ assert.strictEqual(prefs.handleEvent(ev(rev)), true);++ const r = await h.http('GET', '/api/chat/preferences', { token: ann.token });++ assert.deepStrictEqual(r.body.preferences, { hide_emotes: true });++});++++t('another writer between read and write: read again, patch applied on top', async () => {++ h.netModules.beforeWrite = () => h.netModules.set(NS, ANN, { hide_emotes: true, show_badges: false });++ const r = await h.http('PUT', '/api/chat/preferences', { token: ann.token, body: { preferences: { timestamps: true } } });++ assert.strictEqual(r.status, 200, r.text);++ assert.deepStrictEqual(r.body.preferences, { hide_emotes: true, show_badges: false, timestamps: true }, 'nothing of the other write lost');++ assert.deepStrictEqual(record(ANN).data, r.body.preferences);++});++++t('a browser If-Match is strict: 412 when the record moved', async () => {++ const cur = record(ANN).revision;++ let r = await h.http('PUT', '/api/chat/preferences', { token: ann.token, headers: { 'If-Match': `"${cur - 1}"` }, body: { preferences: { compact: true } } });++ assert.strictEqual(r.status, 412); assert.strictEqual(r.body.code, 'prefs.revision_conflict');++ r = await h.http('PUT', '/api/chat/preferences', { token: ann.token, headers: { 'If-Match': `"${cur}"` }, body: { preferences: { compact: true } } });++ assert.strictEqual(r.status, 200); assert.strictEqual(r.body.revision, cur + 1);++ r = await h.http('PUT', '/api/chat/preferences', { token: ann.token, headers: { 'If-Match': 'nope' }, body: { preferences: { compact: true } } });++ assert.strictEqual(r.status, 400);++});++++t('no Network subject: 409; a subject Network does not know reads empty and cannot write', async () => {++ let r = await h.http('GET', '/api/chat/preferences', { token: nosub.token });++ assert.strictEqual(r.status, 409); assert.strictEqual(r.body.code, 'prefs.subject_unknown');++ r = await h.http('GET', '/api/chat/preferences', { token: outsider.token });++ assert.strictEqual(r.status, 200); assert.deepStrictEqual(r.body.preferences, {});++ r = await h.http('PUT', '/api/chat/preferences', { token: outsider.token, body: { preferences: { compact: true } } });++ assert.strictEqual(r.status, 409); assert.strictEqual(r.body.code, 'prefs.subject_unknown');++});++++t('API tokens read, never write', async () => {++ let r = await h.http('GET', '/api/chat/preferences', { token: bot.token });++ assert.strictEqual(r.status, 200);++ r = await h.http('PUT', '/api/chat/preferences', { token: bot.token, body: { preferences: { compact: true } } });++ assert.strictEqual(r.status, 403); assert.strictEqual(r.body.code, 'prefs.token_denied');++});++++t('Network down: the cached copy (stale) or 503; writes 503', async () => {++ await h.http('GET', '/api/chat/preferences', { token: bob.token }); // bob cached, empty++ clock.t += 60_001;++ h.netModules.down = true;++ try {++ let r = await h.http('GET', '/api/chat/preferences', { token: bob.token });++ assert.strictEqual(r.status, 200); assert.strictEqual(r.body.stale, true);++ prefs.invalidate(BOB);++ r = await h.http('GET', '/api/chat/preferences', { token: bob.token });++ assert.strictEqual(r.status, 503); assert.strictEqual(r.body.code, 'prefs.unavailable');++ r = await h.http('PUT', '/api/chat/preferences', { token: bob.token, body: { preferences: { compact: true } } });++ assert.strictEqual(r.status, 503);++ } finally { h.netModules.down = false; }++ const r = await h.http('PUT', '/api/chat/preferences', { token: bob.token, body: { preferences: { compact: true } } });++ assert.strictEqual(r.status, 200, r.text);++ assert.deepStrictEqual(record(BOB).data, { compact: true });++});++++t.run(async () => { if (h) await h.close(); });
@@ -42,6 +42,13 @@ OV_LIVE_INTERNAL_URL=http://127.0.0.1:3000
@@ -42,6 +42,13 @@ OV_LIVE_INTERNAL_URL=http://127.0.0.1:3000
LIVE_MIRROR=0
LIVE_MIRROR=0
#LIVE_MIRROR_INTERVAL_MS=1000
#LIVE_MIRROR_INTERVAL_MS=1000
+# Chat preferences in the OpenVibe.Network user module chat.preferences (grants: chat network.modules.read
+# and network.modules.write on chat.preferences, audience openvibe.network). Cached per person (ms).
+#CHAT_PREFS_ENABLED=1
+#CHAT_PREFS_TTL_MS=60000
+#CHAT_PREFS_TIMEOUT_MS=3000
+#CHAT_PREFS_CACHE_MAX=5000
+
# OpenVibe.VIP — member badges on chat messages (grant: chat vip.entitlement.check openvibe.vip).
# OpenVibe.VIP — member badges on chat messages (grant: chat vip.entitlement.check openvibe.vip).
# Cache lifetimes (ms): a badge outlives a membership by at most CHAT_VIP_BADGE_TTL_MS after VIP
# Cache lifetimes (ms): a badge outlives a membership by at most CHAT_VIP_BADGE_TTL_MS after VIP
# stops granting; a "no" is re-asked after the deny TTL, a failure after the unavailable TTL.
# stops granting; a "no" is re-asked after the deny TTL, a failure after the unavailable TTL.
@@ -64,6 +64,7 @@ before changing behaviour.** Browser JavaScript does not change; nginx routes th
@@ -64,6 +64,7 @@ before changing behaviour.** Browser JavaScript does not change; nginx routes th
| Live's own chat pushes and writes (AI viewers, relays, donations, `/api/mod`, recaps, calls) | **Live → Chat** | `POST /internal/live/calls` (`chat.live_bridge.write`), presence `GET /internal/live/presence` (`chat.presence.read`) |
| Live's own chat pushes and writes (AI viewers, relays, donations, `/api/mod`, recaps, calls) | **Live → Chat** | `POST /internal/live/calls` (`chat.live_bridge.write`), presence `GET /internal/live/presence` (`chat.presence.read`) |
| Identity | **OpenVibe.Network** | user tokens are resolved by Live (its account links); service tokens from `/oauth/token` |
| Identity | **OpenVibe.Network** | user tokens are resolved by Live (its account links); service tokens from `/oauth/token` |
| Events | **OpenVibe.Events** | `events_outbox` → `POST /api/v1/events` when `EVENTS_URL` is set (`events.event.publish`) |
| Events | **OpenVibe.Events** | `events_outbox` → `POST /api/v1/events` when `EVENTS_URL` is set (`events.event.publish`) |
+| A person's chat preferences | **OpenVibe.Network** user module `chat.preferences` (Chat owns the namespace) | `server/prefs/` → `GET/PUT/DELETE /internal/modules/chat.preferences/:subject` (`network.modules.read` / `.write`), cached per person |
| Member badges | **OpenVibe.VIP** (Billing holds the entitlement) | `server/vip/badges.js` → `POST /api/v1/entitlements/check` with `product: 'chat'` (`vip.entitlement.check`), behind the shared product cache |
| Member badges | **OpenVibe.VIP** (Billing holds the entitlement) | `server/vip/badges.js` → `POST /api/v1/entitlements/check` with `product: 'chat'` (`vip.entitlement.check`), behind the shared product cache |
### Data
### Data
@@ -125,6 +126,36 @@ written by creators, so nothing else of it passes and clients render both as tex
@@ -125,6 +126,36 @@ written by creators, so nothing else of it passes and clients render both as tex
nobody gets a badge. The client is vendored from OpenVibe.VIP (`server/vip/vip-client.js`, copied
nobody gets a badge. The client is vendored from OpenVibe.VIP (`server/vip/vip-client.js`, copied
at VIP 2accbeb) until VIP publishes a tag to pin.
at VIP 2accbeb) until VIP publishes a tag to pin.
+### Chat preferences
+
+`GET /api/chat/preferences` and `PUT /api/chat/preferences { preferences: { … } }` (a patch; `null`
+removes a field; optional `If-Match: <revision>`) read and change the signed-in person's chat
+preferences. They live in OpenVibe.Network as the user module `chat.preferences` (openvibe-contracts
+namespace, schema v1: `timestamps`, `compact`, `font_scale` 0.75–2, `show_badges`, `hide_emotes`), which
+Chat owns since the cutover; Network validates, versions (revision, the ETag here) and announces each
+change as `network.module.updated`. Chat reads and writes with its service token (Network grants `chat
+network.modules.read` and `network.modules.write` on `chat.preferences`) and names the revision it read on
+every write, so another writer is never overwritten (Chat reads again and re-applies the patch; a browser
+If-Match is strict: 412).
+
+- **Cache.** One entry per person for `CHAT_PREFS_TTL_MS` (60 s). Chat's own writes land in it at once; a
+ change made through Network directly (the person's account page) shows within the TTL, and at once
+ when a `network.module.updated` for it reaches `prefs.handleEvent()` (for when Events deliveries reach
+ Chat). Network down: the cached copy with `stale: true`, else 503; writes 503.
+- API tokens read only. A person Live knows without a Network subject gets 409 `prefs.subject_unknown`.
+- **Migration** of what Live kept server-side (`user_preferences.chat_settings`, the browser's whole
+ `chatSettings`): `scripts/migrate-chat-preferences.js`. Only choices move (`showTimestamps` →
+ `timestamps`, `compactMode` → `compact`, `fontSize` small/large → `font_scale` 0.88/1.18, `showBadges`
+ false → `show_badges`); a person with defaults only gets no record, and an existing record is never
+ overwritten (create-only, `If-Match: 0`), so it is safe to re-run. The rest of `chatSettings` (TTS,
+ volumes, cross-feed, notifications, …) has no field in schema v1 and stays in Live for now.
+
+```bash
+node scripts/migrate-chat-preferences.js --live-db /tmp/live-snapshot.db # dry run
+node scripts/migrate-chat-preferences.js --live-db /tmp/live-snapshot.db --apply --backup /root/chat-prefs-$(date +%F).json
+node scripts/migrate-chat-preferences.js --rollback /root/chat-prefs-<date>.json [--apply] # undo that run
+```
+
## Running it
## Running it
```bash
```bash
@@ -156,8 +187,9 @@ server/auth/ token resolution through Live; the chat subset of Liv
@@ -156,8 +187,9 @@ server/auth/ token resolution through Live; the chat subset of Liv
server/bridge/ Live → Chat calls + presence (live-bridge.js); Chat → Live read mirror (live-mirror.js)
server/bridge/ Live → Chat calls + presence (live-bridge.js); Chat → Live read mirror (live-mirror.js)
server/events/outbox.js events.event-envelope@1 outbox and relay
server/events/outbox.js events.event-envelope@1 outbox and relay
server/net/service-auth.js service tokens: client (Chat → others) and guard (others → Chat)
server/net/service-auth.js service tokens: client (Chat → others) and guard (others → Chat)
+server/prefs/ chat preferences in the Network user module chat.preferences (routes, cache, migration from Live)
server/db/ schema.sql, database.js (Live's chat functions, same names and arguments)
server/db/ schema.sql, database.js (Live's chat functions, same names and arguments)
-scripts/ import-from-live, parity-check, mirror-flush
+scripts/ import-from-live, parity-check, mirror-flush, migrate-chat-preferences
docs/ cutover.md, live-patch.diff, capabilities-proposal/
docs/ cutover.md, live-patch.diff, capabilities-proposal/
```
```
@@ -195,7 +227,7 @@ Events: `chat.message.created`, `chat.message.deleted`, `chat.dm.created`, `chat
@@ -195,7 +227,7 @@ Events: `chat.message.created`, `chat.message.deleted`, `chat.dm.created`, `chat
## Depends on
## Depends on
-- OpenVibe.Network
+- OpenVibe.Network (identity, service tokens, the `chat.preferences` user module)
- OpenVibe.Live (until the rest of chat's data moves)
- OpenVibe.Live (until the rest of chat's data moves)
- OpenVibe.Events
- OpenVibe.Events
- OpenVibe.Media
- OpenVibe.Media
@@ -9,11 +9,12 @@
@@ -9,11 +9,12 @@
"domain": "openvibe.live (/ws/chat, /api/chat/, /api/dm/, /api/tts/, /api/sounds via an nginx include in openvibe.live's vhost); openvibe.chat is still the OpenVibe.Sites placeholder",
"domain": "openvibe.live (/ws/chat, /api/chat/, /api/dm/, /api/tts/, /api/sounds via an nginx include in openvibe.live's vhost); openvibe.chat is still the OpenVibe.Sites placeholder",
"contracts": "openvibe-contracts v0.13.0",
"contracts": "openvibe-contracts v0.13.0",
"capabilities": ["chat.live_bridge.write", "chat.presence.read"],
"capabilities": ["chat.live_bridge.write", "chat.presence.read"],
- "capabilitiesUsed": ["live.chat_context.read", "live.chat_effects.write", "live.chat_mirror.write", "events.event.publish", "vip.entitlement.check"],
+ "capabilitiesUsed": ["live.chat_context.read", "live.chat_effects.write", "live.chat_mirror.write", "events.event.publish", "vip.entitlement.check", "network.modules.read", "network.modules.write"],
"capabilitiesRegistered": true,
"capabilitiesRegistered": true,
"events": ["chat.message.created", "chat.message.deleted", "chat.dm.created", "chat.moderation.action"],
"events": ["chat.message.created", "chat.message.deleted", "chat.dm.created", "chat.moderation.action"],
"liveAuthorityFlag": "CHAT_AUTHORITY=chat is set in production live.env since the cutover (2026-09-23 02:03 UTC); Live's chat tables are a read mirror Chat keeps current",
"liveAuthorityFlag": "CHAT_AUTHORITY=chat is set in production live.env since the cutover (2026-09-23 02:03 UTC); Live's chat tables are a read mirror Chat keeps current",
"vip": "Member badges from OpenVibe.VIP (server/vip/badges.js: entitlements/check with product chat, cached 60 s, never blocks sending, fails closed). In code only, not deployed; needs the Network grant chat vip.entitlement.check openvibe.vip and Live's chat UI to render vip_badge / chat_vip_badge",
"vip": "Member badges from OpenVibe.VIP (server/vip/badges.js: entitlements/check with product chat, cached 60 s, never blocks sending, fails closed). In code only, not deployed; needs the Network grant chat vip.entitlement.check openvibe.vip and Live's chat UI to render vip_badge / chat_vip_badge",
+ "preferences": "Chat preferences in the Network user module chat.preferences (server/prefs/: GET/PUT /api/chat/preferences with Chat's service token, cached 60 s, If-Match on every write; scripts/migrate-chat-preferences.js moves the choices in Live's user_preferences). In code only, not deployed; needs Network with the chat.preferences handoff and the grants chat network.modules.read/write on chat.preferences, then the migration, then Live's chat.js calling /api/chat/preferences",
"deployed": true,
"deployed": true,
"deployment": "openvibe-ovh /opt/openvibe.chat, unit openvibe-chat, 127.0.0.1:4400 (loopback); public only through openvibe.live's nginx include for the chat paths; release 45f2b1f; EVENTS_URL set (chat.message.created reaches OpenVibe.Events); restore drill passed 2026-09-23",
"deployment": "openvibe-ovh /opt/openvibe.chat, unit openvibe-chat, 127.0.0.1:4400 (loopback); public only through openvibe.live's nginx include for the chat paths; release 45f2b1f; EVENTS_URL set (chat.message.created reaches OpenVibe.Events); restore drill passed 2026-09-23",
"updated": "2026-09-23"
"updated": "2026-09-23"
new file mode 100644
new file mode 100644
@@ -0,0 +1,82 @@
@@ -0,0 +1,82 @@
+#!/usr/bin/env node
+/**
+ * Move the chat preferences Live kept (user_preferences.chat_settings) into the OpenVibe.Network user
+ * module chat.preferences, which Chat owns. One-off and idempotent; logic in server/prefs/from-live.js.
+ *
+ * node scripts/migrate-chat-preferences.js --live-db <live.db> # dry run (default)
+ * node scripts/migrate-chat-preferences.js --live-db <live.db> --apply --backup <file> # write
+ * node scripts/migrate-chat-preferences.js --rollback <file> [--apply] # undo a run
+ *
+ * Options:
+ * --live-db <file> Live's database (a snapshot is best: sqlite3 live.db ".backup /tmp/live.db"), opened read-only
+ * --apply write; without it nothing is written and the report says what would be
+ * --backup <file> required with --apply; must not exist. Written (0600) and read back before any
+ * write: every record the run will create (none existed before) and its data; the
+ * revision each creation got is added when the run ends. --rollback takes it.
+ * --rollback <file> delete the records that run created, each only while still at the revision it
+ * got (a record the person changed since is kept). Dry run unless --apply.
+ *
+ * Reads the same environment as the server (.env / /etc/openvibe/chat.env): OV_NETWORK_INTERNAL_URL,
+ * OV_OAUTH_CLIENT_ID, OV_OAUTH_CLIENT_SECRET. Chat's principal needs network.modules.read and
+ * network.modules.write on chat.preferences (Network default grants). Every write goes through Network,
+ * which validates it, gives it a revision and emits network.module.updated.
+ *
+ * Only choices move (a value equal to Live's default is not one): showTimestamps, compactMode, fontSize,
+ * showBadges. Prints a JSON report. Exit codes: 0 done (or dry run), 2 refused (bad options), 1 failed.
+ */
+'use strict';
+
+const fs = require('fs');
+
+function usage(msg, code = msg ? 2 : 0) {
+ if (msg) console.error(msg);
+ console.error('usage: node scripts/migrate-chat-preferences.js --live-db <live.db> [--apply --backup <file>]\n'
+ + ' node scripts/migrate-chat-preferences.js --rollback <file> [--apply]');
+ process.exit(code);
+}
+
+function parseArgs(argv) {
+ const out = { liveDb: null, apply: false, backup: null, rollback: null };
+ const value = (i, name) => { const v = argv[i]; if (!v || v.startsWith('--')) usage(`${name} needs a value`); return v; };
+ for (let i = 0; i < argv.length; i++) {
+ const a = argv[i];
+ if (a === '--live-db') out.liveDb = value(++i, a);
+ else if (a === '--backup') out.backup = value(++i, a);
+ else if (a === '--rollback') out.rollback = value(++i, a);
+ else if (a === '--apply') out.apply = true;
+ else if (a === '--dry-run') out.apply = false;
+ else if (a === '-h' || a === '--help') usage();
+ else usage(`unknown argument ${a}`);
+ }
+ if (out.rollback) {
+ if (out.liveDb || out.backup) usage('--rollback takes only the backup file (and --apply)');
+ if (!fs.existsSync(out.rollback)) usage(`no backup at ${out.rollback}`);
+ return out;
+ }
+ if (!out.liveDb) usage('missing --live-db');
+ if (!fs.existsSync(out.liveDb)) usage(`no Live database at ${out.liveDb}`);
+ if (out.apply && !out.backup) usage('--apply needs --backup <file>: what the run creates is recorded there first');
+ if (out.backup && fs.existsSync(out.backup)) usage(`${out.backup} exists already; pick a new backup file`);
+ return out;
+}
+
+async function main() {
+ const args = parseArgs(process.argv.slice(2));
+ const config = require('../server/config');
+ if (!config.oauth.clientSecret) usage('OV_OAUTH_CLIENT_SECRET is not set (Chat\'s service principal writes the records)');
+ const migration = require('../server/prefs/from-live');
+ if (args.rollback) {
+ const report = await migration.rollback(args.rollback, { apply: args.apply });
+ console.log(JSON.stringify(report, null, 2));
+ return;
+ }
+ const Database = require('better-sqlite3');
+ const liveDb = new Database(args.liveDb, { readonly: true, fileMustExist: true });
+ let planned;
+ try { planned = migration.plan(liveDb); } finally { liveDb.close(); }
+ const report = await migration.migrate(planned, { apply: args.apply, backup: args.backup, liveDbPath: args.liveDb, log: (m) => console.error(m) });
+ console.log(JSON.stringify(report, null, 2));
+ if (!args.apply) console.error('dry run: nothing written (add --apply --backup <file> to write)');
+}
+
+main().catch((err) => { console.error(`migrate-chat-preferences failed: ${err.message}`); process.exit(1); });
@@ -176,6 +176,8 @@ function createApp({ chatServer, bridge, mirror, relay }) {
@@ -176,6 +176,8 @@ function createApp({ chatServer, bridge, mirror, relay }) {
next();
next();
});
});
+ // The person's chat preferences: Network user module chat.preferences (server/prefs/).
+ app.use('/api/chat/preferences', require('./prefs/routes'));
app.use('/api/chat', require('./chat/routes'));
app.use('/api/chat', require('./chat/routes'));
app.use('/api/dm', require('./chat/dm-routes'));
app.use('/api/dm', require('./chat/dm-routes'));
app.use('/api/tts', require('./chat/tts-routes'));
app.use('/api/tts', require('./chat/tts-routes'));
@@ -80,6 +80,18 @@ module.exports = {
@@ -80,6 +80,18 @@ module.exports = {
unavailableTtlMs: int(process.env.CHAT_VIP_BADGE_UNAVAILABLE_TTL_MS, 5_000),
unavailableTtlMs: int(process.env.CHAT_VIP_BADGE_UNAVAILABLE_TTL_MS, 5_000),
},
},
+ // A person's chat preferences: the OpenVibe.Network user module chat.preferences, which Chat owns
+ // (server/prefs/). Read and written with Chat's service token (grants network.modules.read and
+ // network.modules.write on chat.preferences, audience openvibe.network). Cached per person for
+ // ttlMs: Chat's own writes update the cache at once, a change made through Network directly shows
+ // within ttlMs (at once when a network.module.updated event reaches prefs.handleEvent()).
+ prefs: {
+ enabled: bool(process.env.CHAT_PREFS_ENABLED, true),
+ ttlMs: int(process.env.CHAT_PREFS_TTL_MS, 60_000),
+ timeoutMs: int(process.env.CHAT_PREFS_TIMEOUT_MS, 3000),
+ maxEntries: int(process.env.CHAT_PREFS_CACHE_MAX, 5000),
+ },
+
// OpenVibe.Events — the outbox relays only when EVENTS_URL is set.
// OpenVibe.Events — the outbox relays only when EVENTS_URL is set.
events: {
events: {
url: strip(process.env.EVENTS_URL || ''),
url: strip(process.env.EVENTS_URL || ''),
new file mode 100644
new file mode 100644
@@ -0,0 +1,140 @@
@@ -0,0 +1,140 @@
+/**
+ * A person's chat preferences: the OpenVibe.Network user module `chat.preferences`
+ * (openvibe-contracts manifests/namespaces/chat.preferences.json, schema v1: timestamps, compact,
+ * font_scale, show_badges, hide_emotes). Chat owns the namespace since the Wave 6 cutover; Network
+ * stores it, validates it, versions it (revision) and announces every change (network.module.updated).
+ *
+ * get(subject) the record ({ preferences, revision, version }); {} and revision 0 when the
+ * person has none. Cached per person for config.prefs.ttlMs; when Network
+ * cannot answer, a cached copy is served (stale: true), else 503.
+ * update(subject, patch) { field: value | null } (null removes the field). Read-modify-write against
+ * Network naming the revision read (If-Match); a revision that moved is read
+ * again, up to 3 times. With expectedRevision (the browser's If-Match) it is
+ * strict instead: 412 when the record moved. A patch that changes nothing
+ * writes nothing.
+ * handleEvent(envelope) network.module.updated for chat.preferences newer than the cached copy
+ * drops it at once (for when Events deliveries reach Chat; until then the TTL
+ * bounds how long a change made outside Chat takes to show).
+ *
+ * Only usr_ subjects: Network keeps modules for accounts, and Chat learns a person's subject from Live.
+ */
+'use strict';
+
+const { modules } = require('openvibe-contracts');
+const config = require('../config');
+const client = require('./network-modules');
+
+const NAMESPACE = 'chat.preferences';
+const USER_SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+const FIELDS = Object.freeze(Object.keys(modules.get(NAMESPACE).schema.properties));
+const MAX_TRIES = 3;
+
+class PrefsError extends Error {
+ constructor(status, code, message, errors) { super(message); this.status = status; this.code = code; if (errors) this.errors = errors; }
+}
+
+const cache = new Map(); // subject → { data, revision, version, at } (insertion order = age)
+let clock = () => Date.now();
+
+function enabled() { return !!(config.prefs.enabled && config.oauth.clientSecret); }
+
+function remember(subject, rec) {
+ cache.delete(subject);
+ cache.set(subject, { data: rec.data, revision: rec.revision, version: rec.version, at: clock() });
+ while (cache.size > Math.max(1, config.prefs.maxEntries)) cache.delete(cache.keys().next().value);
+}
+
+const view = (rec, extra = {}) => ({ namespace: NAMESPACE, version: rec.version, revision: rec.revision, preferences: { ...rec.data }, ...extra });
+
+function unavailable(r) {
+ return new PrefsError(503, 'prefs.unavailable', r.error ? `OpenVibe.Network is unavailable (${r.error})` : `OpenVibe.Network answered ${r.status}`);
+}
+
+function checkSubject(subject) {
+ if (!enabled()) throw new PrefsError(503, 'prefs.disabled', 'chat preferences are not configured on this server');
+ if (!USER_SUBJECT.test(String(subject || ''))) throw new PrefsError(409, 'prefs.subject_unknown', 'this account is not linked to OpenVibe.Network yet');
+}
+
+/** The current record from Network ({ data, revision, version }; none yet = {} at revision 0). */
+async function fetchRecord(subject) {
+ const r = await client.request('GET', NAMESPACE, subject);
+ if (r.status === 200 && r.body && r.body.data && typeof r.body.data === 'object') {
+ return { data: r.body.data, revision: Number(r.body.revision) || 0, version: Number(r.body.version) || 1 };
+ }
+ if (r.status === 404 && r.body && r.body.code === 'modules.not_found') return { data: {}, revision: 0, version: modules.get(NAMESPACE).version };
+ if (r.status === 403) throw new PrefsError(503, 'prefs.not_granted', 'Chat may not read chat.preferences (Network grant missing)');
+ throw unavailable(r);
+}
+
+async function get(subject, { fresh = false } = {}) {
+ checkSubject(subject);
+ const hit = cache.get(subject);
+ if (!fresh && hit && clock() - hit.at < config.prefs.ttlMs) return view(hit);
+ try {
+ const rec = await fetchRecord(subject);
+ remember(subject, rec);
+ return view(rec);
+ } catch (err) {
+ if (hit && err.status === 503) return view(hit, { stale: true });
+ throw err;
+ }
+}
+
+/** A patch's problems: unknown fields, or a value the schema refuses once applied. */
+function checkPatch(patch) {
+ if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw new PrefsError(400, 'prefs.bad_request', 'send { preferences: { field: value } }');
+ const unknown = Object.keys(patch).filter((k) => !FIELDS.includes(k));
+ if (unknown.length) throw new PrefsError(422, 'prefs.invalid', `unknown preference${unknown.length > 1 ? 's' : ''}: ${unknown.join(', ')}`, unknown.map((k) => ({ path: `/${k}`, message: 'not a chat preference' })));
+}
+
+const same = (a, b) => JSON.stringify(Object.entries(a).sort()) === JSON.stringify(Object.entries(b).sort());
+
+async function update(subject, patch, { expectedRevision } = {}) {
+ checkSubject(subject);
+ checkPatch(patch);
+ for (let i = 0; i < MAX_TRIES; i++) {
+ const cur = await fetchRecord(subject);
+ if (expectedRevision !== undefined && cur.revision !== expectedRevision) {
+ remember(subject, cur);
+ throw new PrefsError(412, 'prefs.revision_conflict', `the preferences changed (revision ${cur.revision}, not ${expectedRevision}); read them again`);
+ }
+ const next = { ...cur.data };
+ for (const [k, v] of Object.entries(patch)) { if (v === null) delete next[k]; else next[k] = v; }
+ const v = modules.validateData(NAMESPACE, next);
+ if (!v.valid) throw new PrefsError(422, 'prefs.invalid', 'these values are not allowed', v.errors);
+ if (same(next, cur.data)) { remember(subject, cur); return view(cur); }
+ const r = await client.request('PUT', NAMESPACE, subject, { data: next, revision: cur.revision });
+ if ((r.status === 200 || r.status === 201) && r.body && r.body.data) {
+ const rec = { data: r.body.data, revision: Number(r.body.revision) || 0, version: Number(r.body.version) || 1 };
+ remember(subject, rec);
+ return view(rec);
+ }
+ if (r.status === 412) {
+ if (expectedRevision !== undefined) throw new PrefsError(412, 'prefs.revision_conflict', 'the preferences changed; read them again');
+ continue; // someone else wrote first: read again
+ }
+ if (r.status === 422) throw new PrefsError(422, 'prefs.invalid', 'these values are not allowed', r.body && r.body.errors);
+ if (r.status === 404) throw new PrefsError(409, 'prefs.subject_unknown', 'this account is not known to OpenVibe.Network');
+ if (r.status === 403 || r.status === 409) throw new PrefsError(503, 'prefs.not_granted', `Network refused the write (${(r.body && r.body.code) || r.status})`);
+ throw unavailable(r);
+ }
+ throw new PrefsError(409, 'prefs.busy', 'the preferences kept changing; try again');
+}
+
+function handleEvent(envelope) {
+ if (!envelope || envelope.event_type !== 'network.module.updated' || envelope.source !== 'network') return false;
+ const p = envelope.payload && typeof envelope.payload === 'object' ? envelope.payload : {};
+ const subject = p.owner && p.owner.id;
+ if (p.namespace !== NAMESPACE || !cache.has(subject)) return false;
+ if (!(Number(p.revision) > cache.get(subject).revision)) return false; // ours, or older than what we hold
+ cache.delete(subject);
+ return true;
+}
+
+function invalidate(subject) { return cache.delete(subject); }
+
+/** Tests: an injected clock; clears the cache. */
+function _configure({ now } = {}) { if (now) clock = now; cache.clear(); }
+function _cached(subject) { return cache.get(subject) || null; }
+
+module.exports = { NAMESPACE, FIELDS, PrefsError, enabled, get, update, handleEvent, invalidate, _configure, _cached };
new file mode 100644
new file mode 100644
@@ -0,0 +1,152 @@
@@ -0,0 +1,152 @@
+/**
+ * One-off migration of the chat preferences Live kept server-side into the Network user module
+ * chat.preferences (scripts/migrate-chat-preferences.js is the command line).
+ *
+ * Source: Live's user_preferences.chat_settings — the browser's whole chatSettings object, synced by
+ * public/js/chat.js through PUT /api/auth/preferences — and the person's Network subject from Live's
+ * linked_accounts (as scripts/import-from-live.js does). Live's database is opened read-only.
+ *
+ * What moves (chat.preferences v1 has five fields; the rest of chatSettings has no field yet):
+ * showTimestamps true → timestamps: true
+ * compactMode true → compact: true
+ * fontSize small | large → font_scale 0.88 | 1.18 (Live's .chat-msg 0.75rem / 1rem over its 0.85rem)
+ * showBadges false → show_badges: false
+ * Only choices move: a value equal to Live's default (CHAT_SETTINGS_DEFAULTS) is not written, and a
+ * person with nothing but defaults gets no record.
+ *
+ * Writes go through Network with Chat's service token (never into Network's database), create-only
+ * (If-Match: 0): a person who already has a record keeps it, so a second run changes nothing. Before
+ * writing, the backup file records what each target looked like (always "no record" for what gets
+ * created) and what will be written; after, the revision each creation got. rollback() deletes the
+ * records a run created, only while each is still at that revision (If-Match).
+ */
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+const { modules } = require('openvibe-contracts');
+const client = require('./network-modules');
+
+const NAMESPACE = 'chat.preferences';
+const FONT_SCALE = Object.freeze({ small: 0.88, large: 1.18 });
+const USER_SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+
+/** Live's chatSettings → chat.preferences data (choices only). */
+function fromLiveChatSettings(settings) {
+ const s = settings && typeof settings === 'object' && !Array.isArray(settings) ? settings : {};
+ const out = {};
+ if (s.showTimestamps === true) out.timestamps = true;
+ if (s.compactMode === true) out.compact = true;
+ if (Object.prototype.hasOwnProperty.call(FONT_SCALE, s.fontSize)) out.font_scale = FONT_SCALE[s.fontSize];
+ if (s.showBadges === false) out.show_badges = false;
+ return out;
+}
+
+/** Read Live's rows and decide what each becomes. Returns { rows: [...], counts }. No network. */
+function plan(liveDb) {
+ const has = (t) => !!liveDb.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(t);
+ if (!has('user_preferences')) throw new Error('Live database has no user_preferences table');
+ const laCols = has('linked_accounts') ? liveDb.prepare('PRAGMA table_info(linked_accounts)').all().map((c) => c.name) : [];
+ const subjectSql = laCols.includes('subject_id')
+ ? `(SELECT la.subject_id FROM linked_accounts la WHERE la.user_id = up.user_id AND la.subject_id IS NOT NULL
+ ORDER BY (la.service = 'network') DESC, la.id LIMIT 1)`
+ : 'NULL';
+ const rows = liveDb.prepare(`SELECT up.user_id, up.chat_settings, up.updated_at, ${subjectSql} AS subject_id
+ FROM user_preferences up ORDER BY up.user_id`).all();
+ const counts = { live_rows: rows.length, unreadable: 0, no_subject: 0, defaults_only: 0, invalid: 0, candidates: 0, fields: {} };
+ const out = [];
+ for (const r of rows) {
+ let settings;
+ try { settings = JSON.parse(r.chat_settings || '{}'); } catch { counts.unreadable++; continue; }
+ if (!USER_SUBJECT.test(String(r.subject_id || ''))) { counts.no_subject++; continue; }
+ const data = fromLiveChatSettings(settings);
+ if (!Object.keys(data).length) { counts.defaults_only++; continue; }
+ if (!modules.validateData(NAMESPACE, data).valid) { counts.invalid++; continue; }
+ for (const k of Object.keys(data)) counts.fields[k] = (counts.fields[k] || 0) + 1;
+ counts.candidates++;
+ out.push({ live_user_id: r.user_id, subject: r.subject_id, live_updated_at: r.updated_at || null, data });
+ }
+ return { rows: out, counts };
+}
+
+/** The current record of each candidate on Network: null (none), the record, or throws when unreachable. */
+async function current(subject) {
+ const r = await client.request('GET', NAMESPACE, subject);
+ if (r.status === 200 && r.body) return { revision: r.body.revision, data: r.body.data, updated_by: r.body.updated_by || null };
+ if (r.status === 404 && r.body && r.body.code === 'modules.not_found') return null;
+ throw new Error(`reading ${subject} on Network: ${r.status}${r.error ? ` ${r.error}` : ''}${r.body && r.body.code ? ` ${r.body.code}` : ''}`);
+}
+
+function writeJsonAtomic(file, value) {
+ const tmp = `${file}.tmp-${process.pid}`;
+ fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
+ fs.renameSync(tmp, file);
+}
+
+/**
+ * Check every candidate against Network; with apply, write the backup first, then create the records.
+ * Returns the report. `backup` must name a file that does not exist yet (required with apply).
+ */
+async function migrate(planned, { apply = false, backup = null, liveDbPath = null, log = () => {} } = {}) {
+ if (apply && !backup) throw new Error('--apply needs --backup <file>');
+ const report = { mode: apply ? 'apply' : 'dry-run', ...planned.counts, exists: 0, would_create: 0, created: 0, raced: 0, refused: 0 };
+ const targets = [];
+ for (const row of planned.rows) {
+ const before = await current(row.subject);
+ if (before) { report.exists++; continue; }
+ targets.push({ ...row, before: null });
+ }
+ report.would_create = targets.length;
+ if (!apply) return report;
+
+ const file = path.resolve(backup);
+ if (fs.existsSync(file)) throw new Error(`${file} exists already; pick a new backup file`);
+ const doc = { kind: 'openvibe-chat/chat-preferences-migration', version: 1, namespace: NAMESPACE, created_at: new Date().toISOString(),
+ live_db: liveDbPath, targets: targets.map((t) => ({ live_user_id: t.live_user_id, subject: t.subject, before: null, data: t.data })), results: null };
+ writeJsonAtomic(file, doc);
+ const check = JSON.parse(fs.readFileSync(file, 'utf8'));
+ if (!check || !Array.isArray(check.targets) || check.targets.length !== targets.length) throw new Error(`backup ${file} did not read back; nothing written`);
+ log(`backup written: ${file} (${targets.length} target(s))`);
+
+ const results = [];
+ for (const t of targets) {
+ const r = await client.request('PUT', NAMESPACE, t.subject, { data: t.data, revision: 0 });
+ if (r.status === 201 && r.body) { report.created++; results.push({ subject: t.subject, outcome: 'created', revision: r.body.revision }); }
+ else if (r.status === 412) { report.raced++; results.push({ subject: t.subject, outcome: 'exists' }); }
+ else if (r.status === 0) {
+ doc.results = results; writeJsonAtomic(file, doc);
+ throw new Error(`Network became unreachable after ${results.length} write(s): ${r.error}; the backup lists what was done, re-run to continue`);
+ } else { report.refused++; results.push({ subject: t.subject, outcome: 'refused', status: r.status, code: (r.body && r.body.code) || null }); }
+ }
+ doc.results = results;
+ doc.finished_at = new Date().toISOString();
+ writeJsonAtomic(file, doc);
+ return report;
+}
+
+/**
+ * Undo a run from its backup: delete each record it created, only while still at the revision it got
+ * (for a run that stopped before recording results: only while the data is exactly what it wrote).
+ */
+async function rollback(file, { apply = false } = {}) {
+ const doc = JSON.parse(fs.readFileSync(file, 'utf8'));
+ if (!doc || doc.kind !== 'openvibe-chat/chat-preferences-migration' || !Array.isArray(doc.targets)) throw new Error(`${file} is not a chat-preferences migration backup`);
+ const byResult = new Map((doc.results || []).map((r) => [r.subject, r]));
+ const report = { mode: apply ? 'apply' : 'dry-run', targets: doc.targets.length, deleted: 0, would_delete: 0, gone: 0, changed_since: 0, not_created: 0 };
+ for (const t of doc.targets) {
+ const res = byResult.get(t.subject);
+ if (res && res.outcome !== 'created') { report.not_created++; continue; }
+ const cur = await current(t.subject);
+ if (!cur) { report.gone++; continue; }
+ const ours = res ? cur.revision === res.revision : JSON.stringify(cur.data) === JSON.stringify(t.data) && cur.updated_by === 'svc:chat';
+ if (!ours) { report.changed_since++; continue; }
+ if (!apply) { report.would_delete++; continue; }
+ const r = await client.request('DELETE', NAMESPACE, t.subject, { revision: cur.revision });
+ if (r.status === 204) report.deleted++;
+ else if (r.status === 412 || r.status === 404) report.changed_since++;
+ else throw new Error(`deleting ${t.subject}: Network answered ${r.status}${r.error ? ` ${r.error}` : ''}`);
+ }
+ return report;
+}
+
+module.exports = { NAMESPACE, FONT_SCALE, fromLiveChatSettings, plan, migrate, rollback };
new file mode 100644
new file mode 100644
@@ -0,0 +1,41 @@
@@ -0,0 +1,41 @@
+/**
+ * Chat's client for OpenVibe.Network user modules (Network server/identity/modules.js, service routes):
+ *
+ * GET /internal/modules/:ns/:subject network.modules.read
+ * PUT /internal/modules/:ns/:subject If-Match: <rev> network.modules.write (owner service only)
+ * DELETE /internal/modules/:ns/:subject If-Match: <rev> network.modules.write (owner service only)
+ *
+ * with Chat's service token for audience openvibe.network (server/net/service-auth.js). A 401 drops the
+ * cached token and tries once more. Network down, slow or without a token for us → status 0 with
+ * `error`; every other answer is returned as { status, body } for the caller to judge.
+ */
+'use strict';
+
+const config = require('../config');
+const serviceAuth = require('../net/service-auth');
+
+const AUDIENCE = 'openvibe.network';
+const SCOPE = 'network.modules.read network.modules.write';
+
+async function request(method, namespace, subject, { data, revision } = {}) {
+ const url = `${config.networkInternalUrl}/internal/modules/${encodeURIComponent(namespace)}/${encodeURIComponent(subject)}`;
+ for (let attempt = 0; attempt < 2; attempt++) {
+ let auth;
+ try { auth = await serviceAuth.headers(AUDIENCE, SCOPE); } catch (err) { return { status: 0, error: `no service token: ${err.message}` }; }
+ const headers = { ...auth, Accept: 'application/json' };
+ if (data !== undefined) headers['Content-Type'] = 'application/json';
+ if (revision !== undefined && revision !== null) headers['If-Match'] = `"${Number(revision)}"`;
+ let res;
+ try {
+ res = await fetch(url, { method, headers, body: data !== undefined ? JSON.stringify({ data }) : undefined, signal: AbortSignal.timeout(config.prefs.timeoutMs) });
+ } catch (err) {
+ return { status: 0, error: `Network unreachable: ${err.message}` };
+ }
+ if (res.status === 401 && attempt === 0) { serviceAuth.invalidate(AUDIENCE, SCOPE); continue; }
+ const body = res.status === 204 ? null : await res.json().catch(() => null);
+ return { status: res.status, body };
+ }
+ return { status: 401, body: null };
+}
+
+module.exports = { AUDIENCE, SCOPE, request };
new file mode 100644
new file mode 100644
@@ -0,0 +1,44 @@
@@ -0,0 +1,44 @@
+/**
+ * The signed-in person's chat preferences (Network user module chat.preferences, ./chat-preferences.js).
+ * Mounted at /api/chat/preferences (nginx already sends /api/chat/ to Chat).
+ *
+ * GET /api/chat/preferences → { namespace, version, revision, preferences, stale? }
+ * PUT /api/chat/preferences { preferences } change these fields (null removes one); optional
+ * If-Match: <revision> makes it fail with 412 if they moved
+ *
+ * Field names are the namespace's (timestamps, compact, font_scale, show_badges, hide_emotes). ETag is
+ * the revision. API tokens (hbt_) may read, never write: preferences are the person's own.
+ */
+'use strict';
+
+const express = require('express');
+const { requireAuth } = require('../auth/auth');
+const prefs = require('./chat-preferences');
+
+const router = express.Router();
+
+function fail(res, err) {
+ if (err instanceof prefs.PrefsError) {
+ return res.status(err.status).json({ error: err.message, code: err.code, ...(err.errors ? { errors: err.errors } : {}) });
+ }
+ console.error('[Prefs] unexpected error:', err && err.message);
+ return res.status(500).json({ error: 'Internal server error' });
+}
+
+const reply = (res, out) => res.set('Cache-Control', 'private, no-store').set('ETag', `"${out.revision}"`).json(out);
+
+router.get('/', requireAuth, async (req, res) => {
+ try { reply(res, await prefs.get(req.user.subject_id)); } catch (err) { fail(res, err); }
+});
+
+router.put('/', requireAuth, async (req, res) => {
+ if (req.authSource === 'api_token') return res.status(403).json({ error: 'API tokens cannot change chat preferences', code: 'prefs.token_denied' });
+ let expectedRevision;
+ if (req.headers['if-match'] !== undefined) {
+ expectedRevision = Number(String(req.headers['if-match']).replace(/^W\//, '').replace(/"/g, ''));
+ if (!Number.isInteger(expectedRevision) || expectedRevision < 0) return res.status(400).json({ error: 'If-Match must be a revision number', code: 'prefs.bad_revision' });
+ }
+ try { reply(res, await prefs.update(req.user.subject_id, req.body && req.body.preferences, { expectedRevision })); } catch (err) { fail(res, err); }
+});
+
+module.exports = router;
@@ -9,6 +9,7 @@
@@ -9,6 +9,7 @@
* const ws = await h.ws({ ip, token, stream }); ws.next(pred), ws.send(obj), ws.all
* const ws = await h.ws({ ip, token, stream }); ws.next(pred), ws.send(obj), ws.all
* await h.http('GET', '/api/chat/…', { token })
* await h.http('GET', '/api/chat/…', { token })
* h.userToken(userId), h.serviceToken(caps, { aud })
* h.userToken(userId), h.serviceToken(caps, { aud })
+ * h.netModules — the stub Network's user modules (records, subjects, calls, down, beforeWrite)
*/
*/
const fs = require('fs');
const fs = require('fs');
const os = require('os');
const os = require('os');
@@ -46,9 +47,59 @@ async function boot({ env = {} } = {}) {
@@ -46,9 +47,59 @@ async function boot({ env = {} } = {}) {
// ── Stub Network ──
// ── Stub Network ──
const tokenRequests = [];
const tokenRequests = [];
+ // User modules (Network server/identity/modules.js semantics): records per (namespace, subject) with
+ // revisions that never go back, If-Match, schema/quota checks, service tokens for openvibe.network.
+ // subjects = the usr_ ids Network knows; beforeWrite(req) may change a record to stage a race.
+ const { modules: nsLib } = require('openvibe-contracts');
+ const netModules = { records: new Map(), revisions: new Map(), subjects: new Set(), calls: [], down: false, beforeWrite: null };
+ netModules.key = (ns, subject) => `${ns}|${subject}`;
+ netModules.set = (ns, subject, data, by = 'user:' + subject) => {
+ const k = netModules.key(ns, subject);
+ const cur = netModules.records.get(k);
+ const revision = Math.max(cur ? cur.revision : 0, netModules.revisions.get(k) || 0) + 1;
+ netModules.revisions.set(k, revision);
+ netModules.records.set(k, { data, revision, version: nsLib.get(ns).version, updated_by: by, updated_at: sqliteNow() });
+ return revision;
+ };
+ const moduleRecord = (ns, subject, r) => ({ subject: { type: 'user', id: subject }, namespace: ns, version: r.version, revision: r.revision, data: r.data, updated_at: r.updated_at, updated_by: r.updated_by });
+ function modulesRoute(req, res, raw, ns, subject) {
+ const send = (status, body) => { res.statusCode = status; res.end(body === null ? undefined : JSON.stringify(body)); };
+ netModules.calls.push({ method: req.method, ns, subject, ifMatch: req.headers['if-match'] });
+ if (netModules.down) { req.socket.destroy(); return; }
+ const auth = String(req.headers.authorization || '');
+ const v = auth.startsWith('Bearer ') ? serviceAuth.verifyServiceToken(auth.slice(7), { publicKey: keys.publicKey, issuer: ISS, audience: 'openvibe.network' }) : { ok: false, code: 'token.missing' };
+ if (!v.ok) return send(401, { code: v.code });
+ if (!(v.claims.cap || []).includes(req.method === 'GET' ? 'network.modules.read' : 'network.modules.write')) return send(403, { code: 'capability.denied' });
+ const def = nsLib.get(ns);
+ if (!def) return send(404, { code: 'modules.unknown_namespace' });
+ const k = netModules.key(ns, subject);
+ const known = netModules.subjects.has(subject);
+ if (req.method === 'GET') {
+ const r = known && netModules.records.get(k);
+ return r ? send(200, moduleRecord(ns, subject, r)) : send(404, { code: 'modules.not_found' });
+ }
+ if (!known) return send(404, { code: 'identity.subject_not_found' });
+ if (netModules.beforeWrite) { const f = netModules.beforeWrite; netModules.beforeWrite = null; f(req); }
+ const cur = netModules.records.get(k);
+ const have = cur ? cur.revision : 0;
+ if (req.headers['if-match'] !== undefined && Number(String(req.headers['if-match']).replace(/"/g, '')) !== have) return send(412, { code: 'modules.revision_conflict' });
+ if (req.method === 'DELETE') {
+ if (!cur) return send(404, { code: 'modules.not_found' });
+ netModules.records.delete(k);
+ netModules.revisions.set(k, Math.max(have, netModules.revisions.get(k) || 0) + 1);
+ return send(204, null);
+ }
+ let data; try { data = JSON.parse(raw).data; } catch { data = undefined; }
+ const val = nsLib.validateData(ns, data);
+ if (!val.valid) return send(422, { code: 'modules.invalid_data', errors: val.errors });
+ netModules.set(ns, subject, data, `svc:${String(v.claims.sub).replace(/^svc:/, '')}`);
+ return send(cur ? 200 : 201, moduleRecord(ns, subject, netModules.records.get(k)));
+ }
const network = http.createServer(async (req, res) => {
const network = http.createServer(async (req, res) => {
const raw = await new Promise((r) => { let s = ''; req.on('data', (c) => { s += c; }); req.on('end', () => r(s)); });
const raw = await new Promise((r) => { let s = ''; req.on('data', (c) => { s += c; }); req.on('end', () => r(s)); });
res.setHeader('Content-Type', 'application/json');
res.setHeader('Content-Type', 'application/json');
+ const mm = /^\/internal\/modules\/([^/?]+)\/([^/?]+)$/.exec(req.url);
+ if (mm) return modulesRoute(req, res, raw, decodeURIComponent(mm[1]), decodeURIComponent(mm[2]));
if (req.url === '/oauth/token') {
if (req.url === '/oauth/token') {
const f = new URLSearchParams(raw);
const f = new URLSearchParams(raw);
tokenRequests.push({ audience: f.get('audience'), scope: f.get('scope'), client: f.get('client_id') });
tokenRequests.push({ audience: f.get('audience'), scope: f.get('scope'), client: f.get('client_id') });
@@ -239,7 +290,7 @@ async function boot({ env = {} } = {}) {
@@ -239,7 +290,7 @@ async function boot({ env = {} } = {}) {
});
});
const h = {
const h = {
- tmp, keys, live, network, liveServer, tokenRequests, serviceToken, ISS,
+ tmp, keys, live, network, liveServer, tokenRequests, serviceToken, ISS, netModules,
userSeq: 0,
userSeq: 0,
/** Add a Live user (projection row) with a browser token; returns { id, token, ...row }. */
/** Add a Live user (projection row) with a browser token; returns { id, token, ...row }. */
addUser(username, { role = 'user', subject = null, created_at = sqliteNow(-30 * 86400e3), apiScopes = null, is_owner = 0, display_name = null } = {}) {
addUser(username, { role = 'user', subject = null, created_at = sqliteNow(-30 * 86400e3), apiScopes = null, is_owner = 0, display_name = null } = {}) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,159 @@
@@ -0,0 +1,159 @@
+'use strict';
+/**
+ * scripts/migrate-chat-preferences.js (server/prefs/from-live.js): Live's user_preferences.chat_settings
+ * → the Network user module chat.preferences, through Network with Chat's service token.
+ *
+ * - mapping: only choices move (showTimestamps, compactMode, fontSize, showBadges); defaults-only rows,
+ * rows without a Network subject and unreadable rows are counted and skipped
+ * - dry run by default: reads Network, writes nothing
+ * - --apply requires --backup (a new file); the backup is written, 0600, before any write and gets
+ * each creation's revision; writes are create-only (If-Match: 0), so a second run creates nothing
+ * - --rollback deletes what the run created, keeping records changed since
+ * - the command line: refusals exit 2, a dry run through the real script exits 0
+ */
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const { spawn } = require('child_process');
+const Database = require('better-sqlite3');
+const { boot, suite } = require('./helpers');
+
+const t = suite('migrate-preferences');
+const NS = 'chat.preferences';
+const S = {
+ ann: 'usr_01J9ANN0000000000000000AAA', eve: 'usr_01J9EVE0000000000000000AAA', fay: 'usr_01J9FAY0000000000000000AAA',
+ gus: 'usr_01J9GVS0000000000000000AAA', gusOld: 'usr_01J9GVS0000000000000000ZZZ', bob: 'usr_01J9B0B0000000000000000AAA',
+};
+const SCRIPT = path.join(__dirname, '..', 'scripts', 'migrate-chat-preferences.js');
+let h, mig, livePath;
+const liveDefaults = { showTimestamps: false, timestampFormat: '12h', fontSize: 'default', showAvatars: true, showBadges: true, compactMode: false, ttsEnabled: true, ttsVolume: 80, ttsSendModeV2: true };
+const record = (subject) => h.netModules.records.get(`${NS}|${subject}`) || null;
+const puts = () => h.netModules.calls.filter((c) => c.method === 'PUT');
+// Async: the stub Network answers from this process, so it must keep running while the script does.
+const run = (args) => new Promise((resolve) => {
+ const child = spawn(process.execPath, [SCRIPT, ...args], { cwd: path.join(__dirname, '..'), env: process.env });
+ let stdout = ''; let stderr = '';
+ child.stdout.on('data', (c) => { stdout += c; });
+ child.stderr.on('data', (c) => { stderr += c; });
+ child.on('close', (status) => resolve({ status, stdout, stderr }));
+});
+
+t('a Live database with every kind of row', async () => {
+ h = await boot();
+ mig = require('../server/prefs/from-live');
+ livePath = path.join(h.tmp, 'live.db');
+ const live = new Database(livePath);
+ live.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT);
+ CREATE TABLE linked_accounts (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, service TEXT, service_user_id TEXT, subject_id TEXT);
+ CREATE TABLE user_preferences (user_id INTEGER PRIMARY KEY, chat_settings TEXT DEFAULT '{}', updated_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
+ const add = (id, name, settings, links) => {
+ live.prepare('INSERT INTO users (id, username) VALUES (?, ?)').run(id, name);
+ for (const [service, subject] of links) live.prepare('INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (?, ?, ?, ?)').run(id, service, `x${id}`, subject);
+ live.prepare('INSERT INTO user_preferences (user_id, chat_settings) VALUES (?, ?)').run(id, typeof settings === 'string' ? settings : JSON.stringify(settings));
+ };
+ add(1, 'ann', { ...liveDefaults, showTimestamps: true, fontSize: 'large', showBadges: false }, [['network', S.ann]]);
+ add(2, 'bob', liveDefaults, [['network', S.bob]]); // defaults only
+ add(3, 'cat', { ...liveDefaults, compactMode: true }, []); // no subject
+ add(4, 'dan', '{not json', [['network', 'usr_01J9DAN0000000000000000AAA']]); // unreadable
+ add(5, 'eve', { ...liveDefaults, compactMode: true, fontSize: 'small' }, [['network', S.eve]]); // has a record already
+ add(6, 'fay', { compactMode: true }, [['network', S.fay]]); // a partial old blob
+ add(7, 'gus', { ...liveDefaults, fontSize: 'small' }, [['hobostreamer', S.gusOld], ['network', S.gus]]); // the network link wins
+ live.close();
+ for (const s of Object.values(S)) h.netModules.subjects.add(s);
+ h.netModules.set(NS, S.eve, { hide_emotes: true });
+});
+
+t('the mapping keeps choices only', () => {
+ assert.deepStrictEqual(mig.fromLiveChatSettings({ ...liveDefaults, showTimestamps: true, fontSize: 'large', showBadges: false, compactMode: true }),
+ { timestamps: true, compact: true, font_scale: 1.18, show_badges: false });
+ assert.deepStrictEqual(mig.fromLiveChatSettings(liveDefaults), {});
+ assert.deepStrictEqual(mig.fromLiveChatSettings({ fontSize: 'small', showTimestamps: 'yes' }), { font_scale: 0.88 });
+ assert.deepStrictEqual(mig.fromLiveChatSettings(null), {});
+});
+
+t('plan: counts and targets from Live, read-only', () => {
+ const live = new Database(livePath, { readonly: true });
+ const p = mig.plan(live);
+ live.close();
+ assert.deepStrictEqual(p.counts, { live_rows: 7, unreadable: 1, no_subject: 1, defaults_only: 1, invalid: 0, candidates: 4, fields: { timestamps: 1, font_scale: 3, show_badges: 1, compact: 2 } });
+ assert.deepStrictEqual(p.rows.map((r) => [r.live_user_id, r.subject]), [[1, S.ann], [5, S.eve], [6, S.fay], [7, S.gus]]);
+ assert.deepStrictEqual(p.rows[0].data, { timestamps: true, font_scale: 1.18, show_badges: false });
+});
+
+t('the command line refuses unsafe runs (exit 2) and nothing is written', async () => {
+ let r = await run(['--live-db', livePath, '--apply']);
+ assert.strictEqual(r.status, 2, r.stderr); assert.ok(r.stderr.includes('--backup'));
+ const existing = path.join(h.tmp, 'exists.json');
+ fs.writeFileSync(existing, '{}');
+ r = await run(['--live-db', livePath, '--apply', '--backup', existing]);
+ assert.strictEqual(r.status, 2); assert.ok(r.stderr.includes('exists already'));
+ r = await run([]);
+ assert.strictEqual(r.status, 2);
+ r = await run(['--live-db', path.join(h.tmp, 'nope.db')]);
+ assert.strictEqual(r.status, 2);
+ assert.strictEqual(puts().length, 0);
+});
+
+t('dry run through the real script: a report, reads only', async () => {
+ const r = await run(['--live-db', livePath]);
+ assert.strictEqual(r.status, 0, r.stderr);
+ const report = JSON.parse(r.stdout);
+ assert.strictEqual(report.mode, 'dry-run');
+ assert.strictEqual(report.exists, 1, 'eve has one');
+ assert.strictEqual(report.would_create, 3);
+ assert.strictEqual(puts().length, 0);
+ assert.ok(h.tokenRequests.length, 'the script asked Network for a token as chat');
+});
+
+t('--apply --backup: backup first (0600), create-only writes, revisions recorded', async () => {
+ const backup = path.join(h.tmp, 'prefs-backup-1.json');
+ const live = new Database(livePath, { readonly: true });
+ const planned = mig.plan(live);
+ live.close();
+ await assert.rejects(mig.migrate(planned, { apply: true }), /--backup/);
+ h.netModules.beforeWrite = () => {
+ const doc = JSON.parse(fs.readFileSync(backup, 'utf8'));
+ assert.strictEqual(doc.targets.length, 3, 'the backup exists before the first write');
+ assert.strictEqual(doc.results, null);
+ };
+ const report = await mig.migrate(planned, { apply: true, backup, liveDbPath: livePath });
+ assert.deepStrictEqual([report.created, report.exists, report.raced, report.refused], [3, 1, 0, 0]);
+ assert.ok(puts().every((c) => c.ifMatch === '"0"'), 'create-only');
+ assert.deepStrictEqual(record(S.ann).data, { timestamps: true, font_scale: 1.18, show_badges: false });
+ assert.strictEqual(record(S.ann).updated_by, 'svc:chat');
+ assert.deepStrictEqual(record(S.gus).data, { font_scale: 0.88 });
+ assert.deepStrictEqual(record(S.eve).data, { hide_emotes: true }, 'an existing record is never overwritten');
+ assert.strictEqual(record(S.gusOld), null, 'the network link is the subject');
+ assert.strictEqual(fs.statSync(backup).mode & 0o777, 0o600);
+ const doc = JSON.parse(fs.readFileSync(backup, 'utf8'));
+ assert.deepStrictEqual(doc.targets.map((x) => [x.subject, x.before]), [[S.ann, null], [S.fay, null], [S.gus, null]]);
+ assert.deepStrictEqual(doc.results.map((x) => [x.subject, x.outcome, x.revision]), [[S.ann, 'created', 1], [S.fay, 'created', 1], [S.gus, 'created', 1]]);
+});
+
+t('a second run creates nothing', async () => {
+ const n = puts().length;
+ const r = await run(['--live-db', livePath, '--apply', '--backup', path.join(h.tmp, 'prefs-backup-2.json')]);
+ assert.strictEqual(r.status, 0, r.stderr);
+ const report = JSON.parse(r.stdout);
+ assert.deepStrictEqual([report.created, report.exists, report.would_create], [0, 4, 0]);
+ assert.strictEqual(puts().length, n);
+});
+
+t('--rollback deletes what the run created, keeps what changed since', async () => {
+ h.netModules.set(NS, S.fay, { compact: false }); // fay changed hers after the migration
+ const backup = path.join(h.tmp, 'prefs-backup-1.json');
+ let r = await run(['--rollback', backup]);
+ assert.strictEqual(r.status, 0, r.stderr);
+ assert.deepStrictEqual((({ would_delete, changed_since, deleted }) => ({ would_delete, changed_since, deleted }))(JSON.parse(r.stdout)), { would_delete: 2, changed_since: 1, deleted: 0 });
+ assert.ok(record(S.ann), 'dry run');
+ r = await run(['--rollback', backup, '--apply']);
+ assert.strictEqual(r.status, 0, r.stderr);
+ const report = JSON.parse(r.stdout);
+ assert.deepStrictEqual([report.deleted, report.changed_since, report.gone], [2, 1, 0]);
+ assert.strictEqual(record(S.ann), null); assert.strictEqual(record(S.gus), null);
+ assert.deepStrictEqual(record(S.fay).data, { compact: false });
+ assert.deepStrictEqual(record(S.eve).data, { hide_emotes: true }, 'never the migration\'s');
+ assert.ok(h.netModules.calls.filter((c) => c.method === 'DELETE').every((c) => /^"\d+"$/.test(c.ifMatch)), 'deletes name the revision');
+});
+
+t.run(async () => { if (h) await h.close(); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,163 @@
@@ -0,0 +1,163 @@
+'use strict';
+/**
+ * Chat preferences in the Network user module chat.preferences (server/prefs/, roadmap Wave 1 item 24):
+ *
+ * - GET/PUT /api/chat/preferences for the signed-in person, read and written on Network with Chat's
+ * service token (audience openvibe.network, network.modules.read + write), If-Match on every write
+ * - the cache: Chat's writes land in it, reads within CHAT_PREFS_TTL_MS never reach Network, a change
+ * made elsewhere shows after the TTL, or at once through handleEvent(network.module.updated)
+ * - a race with another writer is read again and merged; a browser If-Match is strict (412)
+ * - schema refusals (422), no subject (409), API tokens read-only, Network down (stale copy or 503)
+ *
+ * The stub Network (test/helpers.js) keeps records with Network's revision and If-Match rules.
+ */
+const assert = require('assert');
+const { boot, suite } = require('./helpers');
+
+const t = suite('preferences');
+const ANN = 'usr_01J9ANN0000000000000000AAA';
+const BOB = 'usr_01J9B0B0000000000000000AAA';
+const NS = 'chat.preferences';
+let h, prefs, ann, bob, nosub, bot, outsider;
+const clock = { t: Date.now(), now() { return clock.t; } };
+const calls = (method) => h.netModules.calls.filter((c) => c.method === method).length;
+const record = (subject) => h.netModules.records.get(`${NS}|${subject}`) || null;
+
+t('boot with a stub Network that knows ann and bob', async () => {
+ h = await boot({ env: { CHAT_PREFS_TTL_MS: '60000' } });
+ prefs = require('../server/prefs/chat-preferences');
+ prefs._configure({ now: () => clock.now() });
+ ann = h.addUser('ann', { subject: ANN });
+ bob = h.addUser('bob', { subject: BOB });
+ nosub = h.addUser('nosub');
+ bot = h.addUser('botty', { subject: 'usr_01J9B0TTY000000000000000AA', apiScopes: ['chat'] });
+ outsider = h.addUser('outsider', { subject: 'usr_01J9XTRANGER0000000000000A' });
+ h.netModules.subjects.add(ANN).add(BOB).add('usr_01J9B0TTY000000000000000AA');
+});
+
+t('reading needs a signed-in person; nothing stored yet is {} at revision 0', async () => {
+ let r = await h.http('GET', '/api/chat/preferences');
+ assert.strictEqual(r.status, 401);
+ r = await h.http('GET', '/api/chat/preferences', { token: ann.token });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.deepStrictEqual(r.body, { namespace: NS, version: 1, revision: 0, preferences: {} });
+ assert.strictEqual(r.headers.get('etag'), '"0"');
+ assert.ok(r.headers.get('cache-control').includes('no-store'));
+ const tok = h.tokenRequests.find((x) => x.audience === 'openvibe.network');
+ assert.ok(tok, 'a token for Network was asked for');
+ assert.strictEqual(tok.scope, 'network.modules.read network.modules.write');
+ assert.strictEqual(tok.client, 'chat');
+});
+
+t('a write goes to Network with If-Match and lands in the cache', async () => {
+ const r = await h.http('PUT', '/api/chat/preferences', { token: ann.token, body: { preferences: { timestamps: true, font_scale: 1.18 } } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.deepStrictEqual(r.body.preferences, { timestamps: true, font_scale: 1.18 });
+ assert.strictEqual(r.body.revision, 1);
+ const put = h.netModules.calls.filter((c) => c.method === 'PUT').at(-1);
+ assert.strictEqual(put.ifMatch, '"0"', 'names the revision it read');
+ assert.deepStrictEqual(record(ANN).data, { timestamps: true, font_scale: 1.18 });
+ assert.strictEqual(record(ANN).updated_by, 'svc:chat');
+ const gets = calls('GET');
+ const again = await h.http('GET', '/api/chat/preferences', { token: ann.token });
+ assert.deepStrictEqual(again.body.preferences, { timestamps: true, font_scale: 1.18 });
+ assert.strictEqual(calls('GET'), gets, 'served from the cache');
+});
+
+t('a patch changes only its fields; null removes one; a no-op writes nothing', async () => {
+ let r = await h.http('PUT', '/api/chat/preferences', { token: ann.token, body: { preferences: { compact: true, timestamps: null } } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.deepStrictEqual(r.body.preferences, { font_scale: 1.18, compact: true });
+ assert.strictEqual(r.body.revision, 2);
+ const puts = calls('PUT');
+ r = await h.http('PUT', '/api/chat/preferences', { token: ann.token, body: { preferences: { compact: true } } });
+ assert.strictEqual(r.status, 200); assert.strictEqual(r.body.revision, 2);
+ assert.strictEqual(calls('PUT'), puts, 'nothing changed, nothing written');
+});
+
+t('the namespace schema is enforced', async () => {
+ let r = await h.http('PUT', '/api/chat/preferences', { token: ann.token, body: { preferences: { font_scale: 5 } } });
+ assert.strictEqual(r.status, 422); assert.strictEqual(r.body.code, 'prefs.invalid'); assert.ok(r.body.errors.length);
+ r = await h.http('PUT', '/api/chat/preferences', { token: ann.token, body: { preferences: { ttsVolume: 80 } } });
+ assert.strictEqual(r.status, 422); assert.ok(r.body.error.includes('ttsVolume'));
+ r = await h.http('PUT', '/api/chat/preferences', { token: ann.token, body: { preferences: [true] } });
+ assert.strictEqual(r.status, 400);
+ r = await h.http('PUT', '/api/chat/preferences', { token: ann.token, body: {} });
+ assert.strictEqual(r.status, 400);
+ assert.deepStrictEqual(record(ANN).data, { font_scale: 1.18, compact: true }, 'nothing written');
+});
+
+t('a change made outside Chat shows after the TTL', async () => {
+ h.netModules.set(NS, ANN, { compact: false }); // e.g. the person on my.openvibe.network
+ let r = await h.http('GET', '/api/chat/preferences', { token: ann.token });
+ assert.deepStrictEqual(r.body.preferences, { font_scale: 1.18, compact: true }, 'still the cached copy');
+ clock.t += 60_001;
+ r = await h.http('GET', '/api/chat/preferences', { token: ann.token });
+ assert.deepStrictEqual(r.body.preferences, { compact: false });
+ assert.strictEqual(r.body.revision, 3);
+});
+
+t('network.module.updated newer than the cached copy drops it at once', async () => {
+ const rev = h.netModules.set(NS, ANN, { hide_emotes: true });
+ const ev = (revision, over = {}) => ({ event_type: 'network.module.updated', source: 'network', payload: { owner: { type: 'user', id: ANN }, namespace: NS, revision, change: 'updated', keys: ['hide_emotes'], ...over } });
+ assert.strictEqual(prefs.handleEvent(ev(3)), false, 'not newer than what the cache holds');
+ assert.strictEqual(prefs.handleEvent(ev(rev, { namespace: 'chat.tts_defaults' })), false, 'another namespace');
+ assert.strictEqual(prefs.handleEvent({ ...ev(rev), source: 'live' }), false, 'only Network announces modules');
+ assert.strictEqual(prefs.handleEvent(ev(rev)), true);
+ const r = await h.http('GET', '/api/chat/preferences', { token: ann.token });
+ assert.deepStrictEqual(r.body.preferences, { hide_emotes: true });
+});
+
+t('another writer between read and write: read again, patch applied on top', async () => {
+ h.netModules.beforeWrite = () => h.netModules.set(NS, ANN, { hide_emotes: true, show_badges: false });
+ const r = await h.http('PUT', '/api/chat/preferences', { token: ann.token, body: { preferences: { timestamps: true } } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.deepStrictEqual(r.body.preferences, { hide_emotes: true, show_badges: false, timestamps: true }, 'nothing of the other write lost');
+ assert.deepStrictEqual(record(ANN).data, r.body.preferences);
+});
+
+t('a browser If-Match is strict: 412 when the record moved', async () => {
+ const cur = record(ANN).revision;
+ let r = await h.http('PUT', '/api/chat/preferences', { token: ann.token, headers: { 'If-Match': `"${cur - 1}"` }, body: { preferences: { compact: true } } });
+ assert.strictEqual(r.status, 412); assert.strictEqual(r.body.code, 'prefs.revision_conflict');
+ r = await h.http('PUT', '/api/chat/preferences', { token: ann.token, headers: { 'If-Match': `"${cur}"` }, body: { preferences: { compact: true } } });
+ assert.strictEqual(r.status, 200); assert.strictEqual(r.body.revision, cur + 1);
+ r = await h.http('PUT', '/api/chat/preferences', { token: ann.token, headers: { 'If-Match': 'nope' }, body: { preferences: { compact: true } } });
+ assert.strictEqual(r.status, 400);
+});
+
+t('no Network subject: 409; a subject Network does not know reads empty and cannot write', async () => {
+ let r = await h.http('GET', '/api/chat/preferences', { token: nosub.token });
+ assert.strictEqual(r.status, 409); assert.strictEqual(r.body.code, 'prefs.subject_unknown');
+ r = await h.http('GET', '/api/chat/preferences', { token: outsider.token });
+ assert.strictEqual(r.status, 200); assert.deepStrictEqual(r.body.preferences, {});
+ r = await h.http('PUT', '/api/chat/preferences', { token: outsider.token, body: { preferences: { compact: true } } });
+ assert.strictEqual(r.status, 409); assert.strictEqual(r.body.code, 'prefs.subject_unknown');
+});
+
+t('API tokens read, never write', async () => {
+ let r = await h.http('GET', '/api/chat/preferences', { token: bot.token });
+ assert.strictEqual(r.status, 200);
+ r = await h.http('PUT', '/api/chat/preferences', { token: bot.token, body: { preferences: { compact: true } } });
+ assert.strictEqual(r.status, 403); assert.strictEqual(r.body.code, 'prefs.token_denied');
+});
+
+t('Network down: the cached copy (stale) or 503; writes 503', async () => {
+ await h.http('GET', '/api/chat/preferences', { token: bob.token }); // bob cached, empty
+ clock.t += 60_001;
+ h.netModules.down = true;
+ try {
+ let r = await h.http('GET', '/api/chat/preferences', { token: bob.token });
+ assert.strictEqual(r.status, 200); assert.strictEqual(r.body.stale, true);
+ prefs.invalidate(BOB);
+ r = await h.http('GET', '/api/chat/preferences', { token: bob.token });
+ assert.strictEqual(r.status, 503); assert.strictEqual(r.body.code, 'prefs.unavailable');
+ r = await h.http('PUT', '/api/chat/preferences', { token: bob.token, body: { preferences: { compact: true } } });
+ assert.strictEqual(r.status, 503);
+ } finally { h.netModules.down = false; }
+ const r = await h.http('PUT', '/api/chat/preferences', { token: bob.token, body: { preferences: { compact: true } } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.deepStrictEqual(record(BOB).data, { compact: true });
+});
+
+t.run(async () => { if (h) await h.close(); });