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)
Changes
@@@ -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",@@@ -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",@@@ -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 */ }}@@@ -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);});@@@ -16,17 +16,26 @@ const { serviceAuth, capabilities, assertValid, http } = require('openvibe-contrconst 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) {@@@ -27,6 +27,8 @@ for (const [c, s] of [['live', 'live-secret'], ['tools', 'tools-secret']]) db.prdb.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', econst 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);@@@ -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 });@@@ -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}` });
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0",
"uuid": "^11.1.0",
"uuid": "^11.1.0",
@@ -1465,9 +1465,9 @@
@@ -1465,9 +1465,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -23,7 +23,7 @@
@@ -23,7 +23,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0",
"uuid": "^11.1.0",
"uuid": "^11.1.0",
@@ -238,6 +238,12 @@ a.linked-item:hover{background:var(--bg-hover,var(--bg-card));transform:translat
@@ -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-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 { 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-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>
</style>
</head>
</head>
<body>
<body>
@@ -263,8 +269,9 @@ a.linked-item:hover{background:var(--bg-hover,var(--bg-card));transform:translat
@@ -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="showcase-grid" id="my-showcase-grid"></div>
<div class="quick-access" id="my-quick-access"></div>
<div class="quick-access" id="my-quick-access"></div>
<div class="recent-tools" id="my-recent-tools" hidden>
<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>
<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>
</div>
</div>
@@ -655,31 +662,70 @@ function buildDashboardShowcase() {
@@ -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('');
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 box = document.getElementById('my-recent-tools');
const list = document.getElementById('my-recent-tools-list');
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 {
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 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 */ }
} catch { /* optional */ }
}
}
@@ -113,9 +113,16 @@ function ensureSchema(db) {
@@ -113,9 +113,16 @@ function ensureSchema(db) {
const subjectRef = (sid) => ({ type: sid.startsWith('gst_') ? 'guest' : 'user', id: sid });
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) {
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) {
function read(db, subjectId, namespace) {
@@ -363,6 +370,9 @@ function serviceRoutes(router, principals) {
@@ -363,6 +370,9 @@ function serviceRoutes(router, principals) {
const sid = resolveSubject(db, req.params.subject);
const sid = resolveSubject(db, req.params.subject);
const rec = sid && read(db, sid, req.params.ns);
const rec = sid && read(db, sid, req.params.ns);
if (!rec) return problem(res, { status: 404, code: 'modules.not_found' }, req);
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);
withEtag(res, rec).json(rec);
});
});
@@ -16,17 +16,26 @@ const { serviceAuth, capabilities, assertValid, http } = require('openvibe-contr
@@ -16,17 +16,26 @@ const { serviceAuth, capabilities, assertValid, http } = require('openvibe-contr
const TOKEN_TTL_S = 300;
const TOKEN_TTL_S = 300;
const SELF_AUDIENCE = 'openvibe.network';
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).
// Initial grants: what each service does against Network today (docs/roadmap-baseline/04-cross-service.md).
const DEFAULT_GRANTS = [
const DEFAULT_GRANTS = [
['live', 'network.coins.credit', SELF_AUDIENCE, ['live']],
['live', 'network.coins.credit', SELF_AUDIENCE, ['live']],
['live', 'network.coins.debit', SELF_AUDIENCE, ['live']],
['live', 'network.coins.debit', SELF_AUDIENCE, ['live']],
['live', 'network.notifications.push', 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).
// 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.read', SELF_AUDIENCE, ['tools.usage']],
['tools', 'network.modules.write', SELF_AUDIENCE, ['tools.usage']],
['tools', 'network.modules.write', SELF_AUDIENCE, ['tools.usage']],
['games', 'network.modules.read', SELF_AUDIENCE, ['games.progress.summary']],
['games', 'network.modules.read', SELF_AUDIENCE, ['games.progress.summary']],
@@ -177,10 +186,14 @@ const DEFAULT_GRANTS = [
@@ -177,10 +186,14 @@ const DEFAULT_GRANTS = [
const REVOKED_GRANTS = [['live', 'network.coins.transfer', SELF_AUDIENCE]];
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
// 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 = [
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.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) {
function ensureSchema(db) {
@@ -27,6 +27,8 @@ for (const [c, s] of [['live', 'live-secret'], ['tools', 'tools-secret']]) db.pr
@@ -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();
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.
// 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']));
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);
require('../server/identity/principals').ensureSchema(db);
const ANN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';
const ANN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';
@@ -65,24 +67,25 @@ const valid = (e) => { const v = contracts.validate('events.event-envelope@1', e
@@ -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 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()));
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);
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).
// 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());
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));
assert.strictEqual(chatEvents.scope, 'events.subscription.manage', JSON.stringify(chatEvents));
const chatTok = await svc('chat');
const chatTok = await svc('chat');
const claims = JSON.parse(Buffer.from(chatTok.access_token.split('.')[1], 'base64url').toString());
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 chat = { authorization: `Bearer ${chatTok.access_token}` };
const live = { authorization: `Bearer ${(await svc('live')).access_token}` };
const live = { authorization: `Bearer ${(await svc('live')).access_token}` };
// ── Ownership: chat.preferences belongs to Chat ──
// ── Ownership: chat.preferences belongs to Chat ──
assert.strictEqual(modulesLib.ownerOf('chat.preferences'), '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 } } });
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');
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
@@ -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 ──
// ── 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.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/);
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) });
r = await call('POST', '/api/auth/anon/anon-token-7/link', { headers: tokenOf(1) });
assert.strictEqual(r.status, 200, JSON.stringify(r.body));
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.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);
assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM user_modules WHERE subject_id = ?').get(GUEST).n, 0);
@@ -16,7 +16,13 @@ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-modules-'));
@@ -16,7 +16,13 @@ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-modules-'));
const log = console.log; console.log = () => {};
const log = console.log; console.log = () => {};
const db = initDb(path.join(dir, 'network.db'));
const db = initDb(path.join(dir, 'network.db'));
console.log = log;
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();
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';
const ANN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';
@@ -45,18 +51,18 @@ const server = http.createServer(app);
@@ -45,18 +51,18 @@ const server = http.createServer(app);
// ── The user ──
// ── The user ──
let r = await call('GET', '/api/modules/chat.tts_defaults', { headers: me });
let r = await call('GET', '/api/modules/chat.tts_defaults', { headers: me });
assert.strictEqual(r.status, 404);
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');
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.status, 201, JSON.stringify(r.body));
assert.strictEqual(r.body.revision, 1); assert.strictEqual(r.etag, '"1"');
assert.strictEqual(r.body.revision, 1); assert.strictEqual(r.etag, '"1"');
assert.ok(validate('modules.module-record@1', r.body).valid);
assert.ok(validate('modules.module-record@1', r.body).valid);
assert.deepStrictEqual(r.body.subject, { type: 'user', id: ANN });
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');
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);
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);
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 } } });
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');
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);
@@ -75,7 +81,36 @@ const server = http.createServer(app);
r = await call('GET', `/internal/modules/chat.tts_defaults/${ANN}`, { headers: { authorization: `Bearer ${tools}` } });
r = await call('GET', `/internal/modules/chat.tts_defaults/${ANN}`, { headers: { authorization: `Bearer ${tools}` } });
assert.strictEqual(r.status, 403, 'tools cannot read chat preferences');
assert.strictEqual(r.status, 403, 'tools cannot read chat preferences');
r = await call('GET', `/internal/modules/chat.tts_defaults/${ANN}`, { headers: { authorization: `Bearer ${live}` } });
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 } } });
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.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');
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);
@@ -95,7 +130,7 @@ const server = http.createServer(app);
// ── Export and delete: the user's data ──
// ── Export and delete: the user's data ──
r = await call('GET', '/api/modules', { headers: me });
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.body.namespaces.find(n => n.namespace === 'chat.preferences').userWritable);
assert.ok(r.cache.includes('no-store'));
assert.ok(r.cache.includes('no-store'));
r = await call('DELETE', '/api/modules/live.profile', { headers: me });
r = await call('DELETE', '/api/modules/live.profile', { headers: me });
@@ -121,7 +121,7 @@ const server = http.createServer(app);
@@ -121,7 +121,7 @@ const server = http.createServer(app);
db.prepare("UPDATE oauth_clients SET client_secret = 'community-secret' WHERE client_id = 'community'").run();
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' });
const com = await token({ client_id: 'community', client_secret: 'community-secret', audience: 'openvibe.network' });
assert.strictEqual(com.status, 200, JSON.stringify(com.body));
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}` });
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');
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}` });
r = await post('/internal/identity/resolve-batch', { system: 'network', ids: ['7'] }, { authorization: `Bearer ${creditOnly}` });