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.

OP OpenVibers committed 23 Sept 2026 · c645a53add · 13 files, +890 −4


Changes

ddiff --git a/.env.example b/.env.example
iindex a3e5671..b7209cc 100644
---- a/.env.example
++++ b/.env.example
@@@ -42,6 +42,13 @@ OV_LIVE_INTERNAL_URL=http://127.0.0.1:3000
LIVE_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.
ddiff --git a/README.md b/README.md
iindex 112a489..785d15b 100644
---- a/README.md
++++ b/README.md
@@@ -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 tex
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.
++### 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 Liv
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/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-preferences
docs/ 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
ddiff --git a/STATUS.json b/STATUS.json
iindex b502915..d21d7cf 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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"
ddiff --git a/scripts/migrate-chat-preferences.js b/scripts/migrate-chat-preferences.js
nnew file mode 100644
iindex 0000000..b8420f3
---- /dev/null
++++ b/scripts/migrate-chat-preferences.js
@@@ -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); });
ddiff --git a/server/app.js b/server/app.js
iindex 71f21e0..4f5427e 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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'));
ddiff --git a/server/config.js b/server/config.js
iindex 428f573..2407679 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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 || ''),
ddiff --git a/server/prefs/chat-preferences.js b/server/prefs/chat-preferences.js
nnew file mode 100644
iindex 0000000..9ab880e
---- /dev/null
++++ b/server/prefs/chat-preferences.js
@@@ -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 };
ddiff --git a/server/prefs/from-live.js b/server/prefs/from-live.js
nnew file mode 100644
iindex 0000000..e1c5b47
---- /dev/null
++++ b/server/prefs/from-live.js
@@@ -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 };
ddiff --git a/server/prefs/network-modules.js b/server/prefs/network-modules.js
nnew file mode 100644
iindex 0000000..ed121a0
---- /dev/null
++++ b/server/prefs/network-modules.js
@@@ -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 };
ddiff --git a/server/prefs/routes.js b/server/prefs/routes.js
nnew file mode 100644
iindex 0000000..7644b18
---- /dev/null
++++ b/server/prefs/routes.js
@@@ -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;
ddiff --git a/test/helpers.js b/test/helpers.js
iindex 8e7ca8d..4dddd86 100644
---- a/test/helpers.js
++++ b/test/helpers.js
@@@ -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 } = {}) {
ddiff --git a/test/migrate-preferences.test.js b/test/migrate-preferences.test.js
nnew file mode 100644
iindex 0000000..1d8f242
---- /dev/null
++++ b/test/migrate-preferences.test.js
@@@ -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(); });
ddiff --git a/test/preferences.test.js b/test/preferences.test.js
nnew file mode 100644
iindex 0000000..7c1b41f
---- /dev/null
++++ b/test/preferences.test.js
@@@ -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(); });