openvibers/openvibe.network Commits

User modules (Contracts 0.41.0, WS-B task 9): a service reading another's namespace sees only the fields readers lists for it; stored records read at the current version through the namespace's migrations and are stored at it on the next write; grants for chat.tts_defaults/dm_settings/presence_prefs (Chat), live.stats (Live, which loses its chat.* grants), ai.preferences/ai.usage_summary (AI), community.profile, wiki.projects; my.openvibe.network stars favourite tools (tools.usage v2)

OP OpenVibers committed 25 Sept 2026 · 707504c28d · 8 files, +161 −54


Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 07d9a66c..8847bc04 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -20,7 +20,7 @@
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.40.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.41.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0",
"uuid": "^11.1.0",
@@@ -1465,9 +1465,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.40.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.40.0",
-- "integrity": "sha512-/s6JVX88dC2hC2tDmHQcg9vJGZ2RkdMdulIorL0guIzwPq8o0qG7Vv7wk3bVlCSEhzrH62rt6BgRQmD+wmAz2w==",
++ "version": "0.41.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.41.0",
++ "integrity": "sha512-uT+Prd6NOO7iT2zn2cstxpPa6qmybc/O5KH0AQ8R5alzN/+WyUufd+h/yE0YxAw1+H/mJ4I2Q4xT+Go+CiPW2w==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex d98849d6..a759b310 100644
---- a/package.json
++++ b/package.json
@@@ -23,7 +23,7 @@
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.40.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.41.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0",
"uuid": "^11.1.0",
ddiff --git a/public/my.html b/public/my.html
iindex 56a95530..5d0d46af 100644
---- a/public/my.html
++++ b/public/my.html
@@@ -238,6 +238,12 @@ a.linked-item:hover{background:var(--bg-hover,var(--bg-card));transform:translat
.recent-tools-list { display: flex; flex-wrap: wrap; gap: 6px; }
.recent-tool { padding: 6px 10px; border-radius: 999px; background: var(--bg); border: 1px solid var(--border); color: var(--text-secondary); font-size: 12px; font-weight: 600; text-decoration: none; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.recent-tool:hover { border-color: var(--accent); color: var(--accent-light); }
++.recent-tools-status { margin: 6px 0 0; font-size: 12px; color: var(--text-secondary); }
++.recent-tools-status:empty { display: none; }
++.recent-tool-item { display: inline-flex; align-items: center; gap: 2px; max-width: 100%; }
++.recent-tool-star { border: 0; background: none; color: var(--text-secondary); cursor: pointer; padding: 6px; font-size: 12px; border-radius: 999px; }
++.recent-tool-star[aria-pressed="true"] { color: var(--accent-light); }
++.recent-tool-star:hover, .recent-tool-star:focus-visible { color: var(--accent-light); }
</style>
</head>
<body>
@@@ -263,8 +269,9 @@ a.linked-item:hover{background:var(--bg-hover,var(--bg-card));transform:translat
<div class="showcase-grid" id="my-showcase-grid"></div>
<div class="quick-access" id="my-quick-access"></div>
<div class="recent-tools" id="my-recent-tools" hidden>
-- <h3><i class="fa-solid fa-screwdriver-wrench"></i> Your recent tools</h3>
++ <h3><i class="fa-solid fa-screwdriver-wrench"></i> Your tools</h3>
<div class="recent-tools-list" id="my-recent-tools-list"></div>
++ <p class="recent-tools-status" id="my-recent-tools-status" role="status"></p>
</div>
</div>
@@@ -655,31 +662,70 @@ function buildDashboardShowcase() {
quickAccess.innerHTML = quickLinks.map(l => `<a class="quick-link" onclick="${l.onclick}"><i class="fa-solid ${l.icon}"></i>${l.label}</a>`).join('');
}
--// Recently used tools: the tools.usage module OpenVibe.Tools keeps for this person, named from the
--// public Tools registry (a tool that is gone from it is skipped).
--async function loadRecentTools() {
++// Your tools: the tools.usage module (contracts 0.41.0, v2). OpenVibe.Tools keeps `recent`; `favorites`
++// is the person's own (starred here or in the Tools launcher). Names come from the public Tools
++// registry; a tool that is gone from it is skipped. Writes name the revision read (If-Match).
++let toolsUsage = null, toolsRegistry = null;
++async function saveToolFavorites(next) {
++ for (let attempt = 0; attempt < 2; attempt++) {
++ const data = { ...(toolsUsage ? toolsUsage.data : {}), favorites: next };
++ try {
++ toolsUsage = await apiFetch('/api/modules/tools.usage', { method: 'PUT', headers: { 'If-Match': String(toolsUsage ? toolsUsage.revision : 0) }, body: JSON.stringify({ data }) });
++ return true;
++ } catch (e) {
++ if (e.status !== 412) return false;
++ toolsUsage = await apiFetch('/api/modules/tools.usage').catch(() => null); // moved: read again, then retry
++ }
++ }
++ return false;
++}
++function renderYourTools() {
const box = document.getElementById('my-recent-tools');
const list = document.getElementById('my-recent-tools-list');
-- if (!box || !list) return;
++ if (!box || !list || !toolsRegistry) return;
++ const data = toolsUsage && toolsUsage.data ? toolsUsage.data : {};
++ const favorites = Array.isArray(data.favorites) ? data.favorites : [];
++ const recent = Array.isArray(data.recent) ? data.recent : [];
++ const entries = [...favorites.map(tool => ({ tool, fav: true })), ...recent.filter(e => !favorites.includes(e.tool)).map(e => ({ ...e, fav: false }))];
++ list.replaceChildren();
++ for (const e of entries) {
++ const t = toolsRegistry.get(e.tool);
++ if (!t || list.childElementCount >= 16) continue;
++ const item = document.createElement('span');
++ item.className = 'recent-tool-item';
++ const a = document.createElement('a');
++ a.className = 'recent-tool';
++ a.href = t.hosts && t.hosts[0] ? 'https://' + t.hosts[0] : 'https://openvibe.tools/tool/' + encodeURIComponent(t.id);
++ a.target = '_blank'; a.rel = 'noopener';
++ a.textContent = t.name;
++ if (e.at) a.title = 'Last used ' + new Date(e.at).toLocaleString();
++ const star = document.createElement('button');
++ star.type = 'button';
++ star.className = 'recent-tool-star';
++ star.setAttribute('aria-pressed', String(e.fav));
++ star.setAttribute('aria-label', (e.fav ? 'Remove ' : 'Add ') + t.name + (e.fav ? ' from favourites' : ' to favourites'));
++ star.innerHTML = `<i class="fa-${e.fav ? 'solid' : 'regular'} fa-star"></i>`;
++ star.addEventListener('click', async () => {
++ star.disabled = true;
++ const next = e.fav ? favorites.filter(id => id !== e.tool) : [e.tool, ...favorites].slice(0, 24);
++ const ok = await saveToolFavorites(next);
++ const status = document.getElementById('my-recent-tools-status');
++ if (status) status.textContent = ok ? '' : 'Could not save your favourites. Try again.';
++ renderYourTools();
++ });
++ item.append(a, star);
++ list.appendChild(item);
++ }
++ box.hidden = list.childElementCount === 0;
++}
++async function loadRecentTools() {
try {
-- const rec = await apiFetch('/api/modules/tools.usage').catch(() => null);
-- const recent = rec && rec.data && Array.isArray(rec.data.recent) ? rec.data.recent : [];
-- if (!recent.length) return;
++ toolsUsage = await apiFetch('/api/modules/tools.usage').catch(() => null);
++ const d = toolsUsage && toolsUsage.data ? toolsUsage.data : {};
++ if (!(d.recent || []).length && !(d.favorites || []).length) return;
const reg = await fetchWithTimeout('https://openvibe.tools/api/v1/tools', {}, 8000).then(r => (r.ok ? r.json() : { tools: [] }));
-- const byId = new Map((reg.tools || []).map(t => [t.id, t]));
-- list.replaceChildren();
-- for (const e of recent) {
-- const t = byId.get(e.tool);
-- if (!t || list.childElementCount >= 12) continue;
-- const a = document.createElement('a');
-- a.className = 'recent-tool';
-- a.href = t.hosts && t.hosts[0] ? 'https://' + t.hosts[0] : 'https://openvibe.tools/tool/' + encodeURIComponent(t.id);
-- a.target = '_blank'; a.rel = 'noopener';
-- a.textContent = t.name;
-- if (e.at) a.title = 'Last used ' + new Date(e.at).toLocaleString();
-- list.appendChild(a);
-- }
-- box.hidden = list.childElementCount === 0;
++ toolsRegistry = new Map((reg.tools || []).map(t => [t.id, t]));
++ renderYourTools();
} catch { /* optional */ }
}
ddiff --git a/server/identity/modules.js b/server/identity/modules.js
iindex 9b841e53..67cda4f0 100644
---- a/server/identity/modules.js
++++ b/server/identity/modules.js
@@@ -113,9 +113,16 @@ function ensureSchema(db) {
const subjectRef = (sid) => ({ type: sid.startsWith('gst_') ? 'guest' : 'user', id: sid });
++/**
++ * A stored row as a record at the namespace's current version (its declarative migrations, contract
++ * modules.namespace@1). The row itself is rewritten at the current version on its next write.
++ */
function toRecord(row) {
-- return { subject: subjectRef(row.subject_id), namespace: row.namespace, version: row.version, revision: row.revision,
-- data: JSON.parse(row.data), updated_at: row.updated_at, updated_by: row.updated_by || undefined };
++ let data = JSON.parse(row.data), version = row.version;
++ const up = modules.upgrade(row.namespace, data, version);
++ if (up && up.upgraded) ({ data, version } = up);
++ return { subject: subjectRef(row.subject_id), namespace: row.namespace, version, revision: row.revision,
++ data, updated_at: row.updated_at, updated_by: row.updated_by || undefined };
}
function read(db, subjectId, namespace) {
@@@ -363,6 +370,9 @@ function serviceRoutes(router, principals) {
const sid = resolveSubject(db, req.params.subject);
const rec = sid && read(db, sid, req.params.ns);
if (!rec) return problem(res, { status: 404, code: 'modules.not_found' }, req);
++ // Field-level read rules: the owner reads everything, another service what `readers` lists for it.
++ const svc = serviceOf(req);
++ if (svc !== ownerOf(req.params.ns)) rec.data = modules.serviceView(req.params.ns, svc, rec.data);
withEtag(res, rec).json(rec);
});
ddiff --git a/server/identity/principals.js b/server/identity/principals.js
iindex 0a01b935..98a59fd4 100644
---- a/server/identity/principals.js
++++ b/server/identity/principals.js
@@@ -16,17 +16,26 @@ const { serviceAuth, capabilities, assertValid, http } = require('openvibe-contr
const TOKEN_TTL_S = 300;
const SELF_AUDIENCE = 'openvibe.network';
++const CHAT_NAMESPACES = ['chat.preferences', 'chat.tts_defaults', 'chat.dm_settings', 'chat.presence_prefs'];
++
// Initial grants: what each service does against Network today (docs/roadmap-baseline/04-cross-service.md).
const DEFAULT_GRANTS = [
['live', 'network.coins.credit', SELF_AUDIENCE, ['live']],
['live', 'network.coins.debit', SELF_AUDIENCE, ['live']],
['live', 'network.notifications.push', SELF_AUDIENCE, ['live']],
// User modules: each service reads and writes the namespaces it owns (openvibe-contracts manifests/namespaces).
-- // chat.preferences moved to Chat with the Wave 6 cutover (its owner in the contracts); Live keeps reading it.
-- ['live', 'network.modules.read', SELF_AUDIENCE, ['chat.preferences', 'chat.tts_defaults', 'live.profile']],
-- ['live', 'network.modules.write', SELF_AUDIENCE, ['chat.tts_defaults', 'live.profile']],
-- ['chat', 'network.modules.read', SELF_AUDIENCE, ['chat.preferences']],
-- ['chat', 'network.modules.write', SELF_AUDIENCE, ['chat.preferences']],
++ // The chat.* namespaces are Chat's (chat.preferences since the Wave 6 cutover, chat.tts_defaults since
++ // contracts 0.41.0). A service reading another's namespace sees only the fields `readers` lists for it.
++ ['live', 'network.modules.read', SELF_AUDIENCE, ['live.profile', 'live.stats']],
++ ['live', 'network.modules.write', SELF_AUDIENCE, ['live.profile', 'live.stats']],
++ ['chat', 'network.modules.read', SELF_AUDIENCE, CHAT_NAMESPACES],
++ ['chat', 'network.modules.write', SELF_AUDIENCE, CHAT_NAMESPACES],
++ ['ai', 'network.modules.read', SELF_AUDIENCE, ['ai.preferences', 'ai.usage_summary']],
++ ['ai', 'network.modules.write', SELF_AUDIENCE, ['ai.usage_summary']],
++ ['community', 'network.modules.read', SELF_AUDIENCE, ['community.profile']],
++ ['community', 'network.modules.write', SELF_AUDIENCE, ['community.profile']],
++ ['wiki', 'network.modules.read', SELF_AUDIENCE, ['wiki.projects']],
++ ['wiki', 'network.modules.write', SELF_AUDIENCE, ['wiki.projects']],
['tools', 'network.modules.read', SELF_AUDIENCE, ['tools.usage']],
['tools', 'network.modules.write', SELF_AUDIENCE, ['tools.usage']],
['games', 'network.modules.read', SELF_AUDIENCE, ['games.progress.summary']],
@@@ -177,10 +186,14 @@ const DEFAULT_GRANTS = [
const REVOKED_GRANTS = [['live', 'network.coins.transfer', SELF_AUDIENCE]];
// Default grants whose namespaces changed: a row still exactly as the old default seeded it is moved to
--// the new list at boot (a row someone edited is left alone). Live's write grant lost chat.preferences
--// when the namespace moved to Chat (Wave 6).
++// the new list at boot, in this order (a row someone edited is left alone). Live's write grant lost
++// chat.preferences when the namespace moved to Chat (Wave 6), then chat.tts_defaults (contracts 0.41.0).
const CHANGED_DEFAULT_NAMESPACES = [
['live', 'network.modules.write', SELF_AUDIENCE, ['chat.preferences', 'chat.tts_defaults', 'live.profile'], ['chat.tts_defaults', 'live.profile']],
++ ['live', 'network.modules.write', SELF_AUDIENCE, ['chat.tts_defaults', 'live.profile'], ['live.profile', 'live.stats']],
++ ['live', 'network.modules.read', SELF_AUDIENCE, ['chat.preferences', 'chat.tts_defaults', 'live.profile'], ['live.profile', 'live.stats']],
++ ['chat', 'network.modules.read', SELF_AUDIENCE, ['chat.preferences'], CHAT_NAMESPACES],
++ ['chat', 'network.modules.write', SELF_AUDIENCE, ['chat.preferences'], CHAT_NAMESPACES],
];
function ensureSchema(db) {
ddiff --git a/test/module-events.test.js b/test/module-events.test.js
iindex bf043bcc..6405a0bd 100644
---- a/test/module-events.test.js
++++ b/test/module-events.test.js
@@@ -27,6 +27,8 @@ for (const [c, s] of [['live', 'live-secret'], ['tools', 'tools-secret']]) db.pr
db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES ('chat', 'chat-secret', 'OpenVibe.Chat', '[]', 1)").run();
// A Live write grant as an older boot seeded it (with chat.preferences), to see it narrow.
db.prepare("UPDATE principal_grants SET namespaces = ? WHERE client_id = 'live' AND capability = 'network.modules.write'").run(JSON.stringify(['chat.preferences', 'chat.tts_defaults', 'live.profile']));
++// Chat's grants as a boot before contracts 0.41.0 seeded them (chat.preferences only), to see them widen.
++for (const cap of ['network.modules.read', 'network.modules.write']) db.prepare("INSERT INTO principal_grants (client_id, capability, audience, namespaces, granted_by) VALUES ('chat', ?, 'openvibe.network', '[\"chat.preferences\"]', 'default')").run(cap);
require('../server/identity/principals').ensureSchema(db);
const ANN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';
@@@ -65,24 +67,25 @@ const valid = (e) => { const v = contracts.validate('events.event-envelope@1', e
const tokenOf = (id) => ({ authorization: `Bearer ${signToken(db.prepare('SELECT * FROM users WHERE id = ?').get(id), keys.privateKey, config)}` });
const svc = async (client) => (await fetch(`${base}/oauth/token`, { method: 'POST', body: new URLSearchParams({ grant_type: 'client_credentials', client_id: client, client_secret: `${client}-secret`, audience: 'openvibe.network' }) }).then(r => r.json()));
-- // ── Grants: Chat reads and writes chat.preferences; Live's write grant lost it ──
++ // ── Grants: Chat reads and writes the chat.* namespaces; Live's grants lost them (contracts 0.41.0) ──
++ const CHAT_NS = ['chat.preferences', 'chat.tts_defaults', 'chat.dm_settings', 'chat.presence_prefs'];
const grant = (c, cap) => JSON.parse(db.prepare('SELECT namespaces FROM principal_grants WHERE client_id = ? AND capability = ?').get(c, cap).namespaces);
-- assert.deepStrictEqual(grant('chat', 'network.modules.read'), ['chat.preferences']);
-- assert.deepStrictEqual(grant('chat', 'network.modules.write'), ['chat.preferences']);
-- assert.deepStrictEqual(grant('live', 'network.modules.write'), ['chat.tts_defaults', 'live.profile'], 'the old default narrows at boot');
-- assert.ok(grant('live', 'network.modules.read').includes('chat.preferences'), 'Live keeps reading it');
++ assert.deepStrictEqual(grant('chat', 'network.modules.read'), CHAT_NS);
++ assert.deepStrictEqual(grant('chat', 'network.modules.write'), CHAT_NS);
++ assert.deepStrictEqual(grant('live', 'network.modules.write'), ['live.profile', 'live.stats'], 'the old defaults move to the new one at boot');
++ assert.deepStrictEqual(grant('live', 'network.modules.read'), ['live.profile', 'live.stats']);
// Chat manages its own Events subscriptions (live.release.deployed, network.module.updated).
const chatEvents = await fetch(`${base}/oauth/token`, { method: 'POST', body: new URLSearchParams({ grant_type: 'client_credentials', client_id: 'chat', client_secret: 'chat-secret', audience: 'openvibe.events', scope: 'events.subscription.manage' }) }).then(r => r.json());
assert.strictEqual(chatEvents.scope, 'events.subscription.manage', JSON.stringify(chatEvents));
const chatTok = await svc('chat');
const claims = JSON.parse(Buffer.from(chatTok.access_token.split('.')[1], 'base64url').toString());
-- assert.deepStrictEqual(claims.ns, ['chat.preferences']);
++ assert.deepStrictEqual(claims.ns, CHAT_NS);
const chat = { authorization: `Bearer ${chatTok.access_token}` };
const live = { authorization: `Bearer ${(await svc('live')).access_token}` };
// ── Ownership: chat.preferences belongs to Chat ──
assert.strictEqual(modulesLib.ownerOf('chat.preferences'), 'chat');
-- assert.strictEqual(modulesLib.ownerOf('chat.tts_defaults'), 'live');
++ assert.strictEqual(modulesLib.ownerOf('chat.tts_defaults'), 'chat', 'Chat\'s since contracts 0.41.0');
let r = await call('PUT', `/internal/modules/chat.preferences/${ANN}`, { headers: live, body: { data: { timestamps: true } } });
assert.strictEqual(r.status, 403, 'Live no longer writes chat.preferences');
@@@ -183,11 +186,11 @@ const valid = (e) => { const v = contracts.validate('events.event-envelope@1', e
// ── Guest -> account: POST /api/auth/anon/:token/link moves the guest's modules ──
modulesLib.write(db, GUEST, 'chat.preferences', { compact: true }, { writer: { type: 'service', id: 'chat' } });
-- modulesLib.write(db, GUEST, 'chat.tts_defaults', { rate: 1.5 }, { writer: { type: 'user' }, expectedRevision: 0 });
++ modulesLib.write(db, GUEST, 'chat.tts_defaults', { volume: 15 }, { writer: { type: 'user' }, expectedRevision: 0 });
assert.throws(() => db.prepare('DELETE FROM anon_users WHERE id = 50').run(), /onSubjectRemoved/);
r = await call('POST', '/api/auth/anon/anon-token-7/link', { headers: tokenOf(1) });
assert.strictEqual(r.status, 200, JSON.stringify(r.body));
-- assert.deepStrictEqual(modulesLib.read(db, ANN, 'chat.tts_defaults').data, { rate: 1.5 }, 'moved');
++ assert.deepStrictEqual(modulesLib.read(db, ANN, 'chat.tts_defaults').data, { volume: 15 }, 'moved');
assert.deepStrictEqual(modulesLib.read(db, ANN, 'chat.preferences').data, { hide_emotes: true }, 'the account kept its own');
assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM user_modules WHERE subject_id = ?').get(GUEST).n, 0);
ddiff --git a/test/modules.test.js b/test/modules.test.js
iindex 845463b4..5ad6b42d 100644
---- a/test/modules.test.js
++++ b/test/modules.test.js
@@@ -16,7 +16,13 @@ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-modules-'));
const log = console.log; console.log = () => {};
const db = initDb(path.join(dir, 'network.db'));
console.log = log;
--for (const [c, s] of [['live', 'live-secret'], ['tools', 'tools-secret'], ['games', 'games-secret']]) db.prepare('UPDATE oauth_clients SET client_secret = ? WHERE client_id = ?').run(s, c);
++// chat and ai are registered on the host, not seeded: add them as copies of tools, then seed their grants again.
++for (const c of ['chat', 'ai']) {
++ const cols = db.prepare('PRAGMA table_info(oauth_clients)').all().map(x => x.name).filter(n => n !== 'client_id' && n !== 'id');
++ db.prepare(`INSERT INTO oauth_clients (client_id, ${cols.join(', ')}) SELECT ?, ${cols.join(', ')} FROM oauth_clients WHERE client_id = 'tools'`).run(c);
++}
++require('../server/identity/principals').ensureSchema(db);
++for (const [c, s] of [['live', 'live-secret'], ['tools', 'tools-secret'], ['games', 'games-secret'], ['chat', 'chat-secret'], ['ai', 'ai-secret']]) db.prepare('UPDATE oauth_clients SET client_secret = ? WHERE client_id = ?').run(s, c);
db.prepare("INSERT INTO users (id, username, password_hash, subject_id) VALUES (1, 'ann', 'x', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA'), (2, 'bob', 'x', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPB')").run();
const ANN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';
@@@ -45,18 +51,18 @@ const server = http.createServer(app);
// ── The user ──
let r = await call('GET', '/api/modules/chat.tts_defaults', { headers: me });
assert.strictEqual(r.status, 404);
-- r = await call('PUT', '/api/modules/chat.tts_defaults', { headers: me, body: { data: { voice: 'en-1', rate: 1.2 } } });
++ r = await call('PUT', '/api/modules/chat.tts_defaults', { headers: me, body: { data: { send: true, volume: 40 } } });
assert.strictEqual(r.status, 428, 'a user write must say which revision it read');
-- r = await call('PUT', '/api/modules/chat.tts_defaults', { headers: { ...me, 'if-match': '0' }, body: { data: { voice: 'en-1', rate: 1.2 } } });
++ r = await call('PUT', '/api/modules/chat.tts_defaults', { headers: { ...me, 'if-match': '0' }, body: { data: { send: true, volume: 40 } } });
assert.strictEqual(r.status, 201, JSON.stringify(r.body));
assert.strictEqual(r.body.revision, 1); assert.strictEqual(r.etag, '"1"');
assert.ok(validate('modules.module-record@1', r.body).valid);
assert.deepStrictEqual(r.body.subject, { type: 'user', id: ANN });
-- r = await call('PUT', '/api/modules/chat.tts_defaults', { headers: { ...me, 'if-match': '0' }, body: { data: { voice: 'en-2' } } });
++ r = await call('PUT', '/api/modules/chat.tts_defaults', { headers: { ...me, 'if-match': '0' }, body: { data: { volume: 50 } } });
assert.strictEqual(r.status, 412, 'stale revision refused'); assert.strictEqual(r.body.code, 'modules.revision_conflict');
-- r = await call('PUT', '/api/modules/chat.tts_defaults', { headers: { ...me, 'if-match': '"1"' }, body: { data: { voice: 'en-2', rate: 5 } } });
++ r = await call('PUT', '/api/modules/chat.tts_defaults', { headers: { ...me, 'if-match': '"1"' }, body: { data: { volume: 500 } } });
assert.strictEqual(r.status, 422, 'schema enforced'); assert.ok(r.body.errors.length);
-- r = await call('PUT', '/api/modules/chat.tts_defaults', { headers: { ...me, 'if-match': '"1"' }, body: { data: { voice: 'en-2' } } });
++ r = await call('PUT', '/api/modules/chat.tts_defaults', { headers: { ...me, 'if-match': '"1"' }, body: { data: { volume: 50 } } });
assert.strictEqual(r.status, 200); assert.strictEqual(r.body.revision, 2);
r = await call('PUT', '/api/modules/live.profile', { headers: { ...me, 'if-match': '0' }, body: { data: { followers: 1000000 } } });
assert.strictEqual(r.status, 403, 'users cannot write a service-owned summary'); assert.strictEqual(r.body.code, 'modules.write_denied');
@@@ -75,7 +81,36 @@ const server = http.createServer(app);
r = await call('GET', `/internal/modules/chat.tts_defaults/${ANN}`, { headers: { authorization: `Bearer ${tools}` } });
assert.strictEqual(r.status, 403, 'tools cannot read chat preferences');
r = await call('GET', `/internal/modules/chat.tts_defaults/${ANN}`, { headers: { authorization: `Bearer ${live}` } });
-- assert.strictEqual(r.status, 200); assert.strictEqual(r.body.data.voice, 'en-2');
++ assert.strictEqual(r.status, 403, 'chat.tts_defaults is Chat\'s since contracts 0.41.0: Live has no grant');
++ const chat = await svc('chat');
++ r = await call('GET', `/internal/modules/chat.tts_defaults/${ANN}`, { headers: { authorization: `Bearer ${chat}` } });
++ assert.strictEqual(r.status, 200); assert.strictEqual(r.body.data.volume, 50);
++
++ // ── Field-level read rules (readers) ──
++ r = await call('PUT', '/api/modules/ai.preferences', { headers: { ...me, 'if-match': '0' }, body: { data: { style: 'casual', history: false } } });
++ assert.strictEqual(r.status, 201, JSON.stringify(r.body));
++ const ai = await svc('ai');
++ r = await call('GET', `/internal/modules/ai.preferences/${ANN}`, { headers: { authorization: `Bearer ${ai}` } });
++ assert.deepStrictEqual(r.body.data, { style: 'casual', history: false }, 'ai is a listed reader of ai.preferences');
++ r = await call('PUT', `/internal/modules/live.stats/${ANN}`, { headers: { authorization: `Bearer ${live}` }, body: { data: { streams_30d: 3, peak_viewers_30d: 12, new_followers_30d: 7 } } });
++ assert.strictEqual(r.status, 201, JSON.stringify(r.body));
++ db.prepare("UPDATE principal_grants SET namespaces = '[\"tools.usage\",\"live.stats\"]' WHERE client_id = 'tools' AND capability = 'network.modules.read'").run();
++ const tools2 = await svc('tools');
++ r = await call('GET', `/internal/modules/live.stats/${ANN}`, { headers: { authorization: `Bearer ${tools2}` } });
++ assert.deepStrictEqual(r.body.data, { streams_30d: 3, peak_viewers_30d: 12 }, 'a granted service that is not a listed reader sees public fields only');
++ r = await call('GET', `/internal/modules/live.stats/${ANN}`, { headers: { authorization: `Bearer ${live}` } });
++ assert.strictEqual(r.body.data.new_followers_30d, 7, 'the owner reads the whole record');
++
++ // ── Version migration: a stored v1 record reads as the current version ──
++ db.prepare("INSERT INTO user_modules (subject_id, namespace, version, revision, data) VALUES (?, 'tools.usage', 1, 1, ?)").run(ANN, JSON.stringify({ recent: [{ tool: 'img' }] }));
++ r = await call('GET', '/api/modules/tools.usage', { headers: me });
++ assert.strictEqual(r.body.version, 2); assert.deepStrictEqual(r.body.data, { recent: [{ tool: 'img' }] });
++ r = await call('PUT', '/api/modules/tools.usage', { headers: { ...me, 'if-match': '1' }, body: { data: { ...r.body.data, favorites: ['img'] } } });
++ assert.strictEqual(r.status, 200, 'the person may star tools (tools.usage v2)');
++ assert.strictEqual(db.prepare("SELECT version FROM user_modules WHERE subject_id = ? AND namespace = 'tools.usage'").get(ANN).version, 2, 'the next write stores the current version');
++ db.prepare("INSERT INTO user_modules (subject_id, namespace, version, revision, data) VALUES ('usr_01JAB2C3D4E5F6G7H8J9K0MNPB', 'chat.tts_defaults', 1, 1, ?)").run(JSON.stringify({ voice: 'en-1', rate: 1.2, muted: true }));
++ r = await call('GET', '/internal/modules/chat.tts_defaults/usr_01JAB2C3D4E5F6G7H8J9K0MNPB', { headers: { authorization: `Bearer ${chat}` } });
++ assert.strictEqual(r.body.version, 2); assert.deepStrictEqual(r.body.data, {}, 'v1 tts fields are dropped by the migration');
r = await call('PUT', `/internal/modules/live.profile/${ANN}`, { headers: { 'x-internal-key': 'legacy-key' }, body: { data: { followers: 1 } } });
assert.strictEqual(r.status, 403, 'module routes never accept the shared key');
assert.ok(db.prepare("SELECT 1 FROM principal_usage WHERE principal = 'legacy-key' AND auth = 'internal-key' AND allowed = 0 AND route LIKE 'PUT /internal/modules%'").get(), 'a refused key is audited as the key');
@@@ -95,7 +130,7 @@ const server = http.createServer(app);
// ── Export and delete: the user's data ──
r = await call('GET', '/api/modules', { headers: me });
-- assert.deepStrictEqual(r.body.modules.map(m => m.namespace), ['chat.tts_defaults', 'live.profile']);
++ assert.deepStrictEqual(r.body.modules.map(m => m.namespace), ['ai.preferences', 'chat.tts_defaults', 'live.profile', 'live.stats', 'tools.usage']);
assert.ok(r.body.namespaces.find(n => n.namespace === 'chat.preferences').userWritable);
assert.ok(r.cache.includes('no-store'));
r = await call('DELETE', '/api/modules/live.profile', { headers: me });
ddiff --git a/test/principals.test.js b/test/principals.test.js
iindex 48105507..bf4a5424 100644
---- a/test/principals.test.js
++++ b/test/principals.test.js
@@@ -121,7 +121,7 @@ const server = http.createServer(app);
db.prepare("UPDATE oauth_clients SET client_secret = 'community-secret' WHERE client_id = 'community'").run();
const com = await token({ client_id: 'community', client_secret: 'community-secret', audience: 'openvibe.network' });
assert.strictEqual(com.status, 200, JSON.stringify(com.body));
-- assert.deepStrictEqual(com.body.scope.split(' '), ['identity.subject.resolve']);
++ assert.deepStrictEqual(com.body.scope.split(' '), ['identity.subject.resolve', 'network.modules.read', 'network.modules.write'], 'resolve, plus its community.profile module');
r = await post('/internal/identity/resolve-batch', { system: 'network', ids: ['7'] }, { authorization: `Bearer ${com.body.access_token}` });
assert.strictEqual(r.status, 200); assert.strictEqual(r.body.results['7'].username, 'payee');
r = await post('/internal/identity/resolve-batch', { system: 'network', ids: ['7'] }, { authorization: `Bearer ${creditOnly}` });