openvibers/openvibe.live Commits
Live: cosmetics can live in OpenVibe.Inventory (INVENTORY_AUTHORITY=inventory, ADR-054 §8) — the same cosmetics API reads and writes live.* items there with Live's token acting for the person; live stays the default until the import verifies (#53)
Changes
@@@ -196,4 +196,11 @@ OV_CHAT_INTERNAL_URL=http://127.0.0.1:4400#BILLING_AUTHORITY=billingOV_BILLING_INTERNAL_URL=http://127.0.0.1:4600OV_BILLING_PUBLIC_URL=https://billing.openvibe.network++++# Cosmetics authority (ADR-054 §8, read only in server/monetization/cosmetics.js). live (default): Live's own++# user_cosmetics / user_equipped. inventory: OpenVibe.Inventory owns them (live.* items issued by service:live, Live's++# item ids as aliases); Live reads and writes with its service token (audience openvibe.inventory) acting for the++# person, and keeps only the renderers. Switch after OpenVibe.Inventory's scripts/import-live.js --verify passes.++#INVENTORY_AUTHORITY=inventory++#INVENTORY_URL=http://127.0.0.1:5030# OV_BILLING_TIMEOUT_MS=8000@@@ -4,7 +4,7 @@Self-hosted live streaming platform — Node.js/Express monolith, vanilla JS SPA frontend, PostgreSQL (`openvibe-sdk/db`; embedded PGlite in development and tests; on PostgreSQL since 2026-10-08). Part of the **OpenVibe network** with SSO via openvibe.network. See [README.md](README.md) for features and [docs/architecture.md](docs/architecture.md) for system design.--**Network context:** SSO/OAuth2 + the OpenCoins wallet come from **OpenVibe.Network** (`server/monetization/wallet-client.js`). The media subsystem (VODs/clips/pastes/thumbnails/files) lives in **OpenVibe.Media** — Live talks to it via [server/media-client.js](server/media-client.js), the thin proxy routers in [server/media-proxy/](server/media-proxy/), and the Media-backed recorder [server/streaming/recorder.js](server/streaming/recorder.js). Media completion events arrive over OpenVibe.Events at `POST /internal/media-events` (`MEDIA_EVENTS_AUTHORITY`; the older `POST /internal/media-webhook` stays during the transition). The legacy local `vods`/`clips`/`pastes`/`paste_likes`/`paste_comments` tables are FROZEN: never read or write them (`test/frozen-tables.test.js` enforces it; ask Media or Community through `server/media-proxy/lookups.js`, `media-client.js`, `pastes-client.js`); VOD/clip comments are OpenVibe.Community threads ([server/comments-client.js](server/comments-client.js)) and the local `comments` table is READ-ONLY too; Chat owns every chat table — the staged set (`channel_moderators`, `channel_moderation_settings`, `emotes`, `user_tags`, `chat_ai_summaries`, `chat_timeline_events`; Live reads those only through `server/chat/moderation-client.js`, capability `chat.moderation.read`, cached 30 s, and the summaries through `server/chat/insight-client.js`, cached 60 s) and the twelve the chat cutover imports covered (`chat_messages`, the DM tables, `tts_voice_overrides`, `channel_sounds`, `relay_users`, `hidden_relay_users`, `pending_ip_messages`, `stream_first_chats`, `moderation_actions`); Live keeps no copy of any of them since 2026-10-05 — chat stats, queues, history, first-chat and DM-block state all come through `server/chat/chat-reads.js`, and the twelve tables are dropped at boot by migration `007_drop_chat_tables` (`test/chat-tables-dropped.test.js` guards that nothing under `server/` names them); Live-owned AI/transcript state for Media-hosted content lives in `vod_ai_state`/`clip_ai_state`. See ../CONTRACTS.md for the binding inter-service contracts. **Money:** `BILLING_AUTHORITY` (read only in [server/monetization/money-authority.js](server/monetization/money-authority.js)) is `live` by default; `billing` makes OpenVibe.Billing the ledger ([billing-client.js](server/monetization/billing-client.js), [billing-actions.js](server/monetization/billing-actions.js)) and Live's `openvibe_bucks_*`/`transactions`/`payment_orders`/`subscriptions` read-only (a database-level tripwire throws). The owner-only `money_writes_frozen` flag (`/api/admin/money`) refuses money actions in both modes.++**Network context:** SSO/OAuth2 + the OpenCoins wallet come from **OpenVibe.Network** (`server/monetization/wallet-client.js`). The media subsystem (VODs/clips/pastes/thumbnails/files) lives in **OpenVibe.Media** — Live talks to it via [server/media-client.js](server/media-client.js), the thin proxy routers in [server/media-proxy/](server/media-proxy/), and the Media-backed recorder [server/streaming/recorder.js](server/streaming/recorder.js). Media completion events arrive over OpenVibe.Events at `POST /internal/media-events` (`MEDIA_EVENTS_AUTHORITY`; the older `POST /internal/media-webhook` stays during the transition). The legacy local `vods`/`clips`/`pastes`/`paste_likes`/`paste_comments` tables are FROZEN: never read or write them (`test/frozen-tables.test.js` enforces it; ask Media or Community through `server/media-proxy/lookups.js`, `media-client.js`, `pastes-client.js`); VOD/clip comments are OpenVibe.Community threads ([server/comments-client.js](server/comments-client.js)) and the local `comments` table is READ-ONLY too; Chat owns every chat table — the staged set (`channel_moderators`, `channel_moderation_settings`, `emotes`, `user_tags`, `chat_ai_summaries`, `chat_timeline_events`; Live reads those only through `server/chat/moderation-client.js`, capability `chat.moderation.read`, cached 30 s, and the summaries through `server/chat/insight-client.js`, cached 60 s) and the twelve the chat cutover imports covered (`chat_messages`, the DM tables, `tts_voice_overrides`, `channel_sounds`, `relay_users`, `hidden_relay_users`, `pending_ip_messages`, `stream_first_chats`, `moderation_actions`); Live keeps no copy of any of them since 2026-10-05 — chat stats, queues, history, first-chat and DM-block state all come through `server/chat/chat-reads.js`, and the twelve tables are dropped at boot by migration `007_drop_chat_tables` (`test/chat-tables-dropped.test.js` guards that nothing under `server/` names them); Live-owned AI/transcript state for Media-hosted content lives in `vod_ai_state`/`clip_ai_state`. See ../CONTRACTS.md for the binding inter-service contracts. **Money:** `BILLING_AUTHORITY` (read only in [server/monetization/money-authority.js](server/monetization/money-authority.js)) is `live` by default; `billing` makes OpenVibe.Billing the ledger ([billing-client.js](server/monetization/billing-client.js), [billing-actions.js](server/monetization/billing-actions.js)) and Live's `openvibe_bucks_*`/`transactions`/`payment_orders`/`subscriptions` read-only (a database-level tripwire throws). The owner-only `money_writes_frozen` flag (`/api/admin/money`) refuses money actions in both modes. **Cosmetics:** `INVENTORY_AUTHORITY` (read only in [server/monetization/cosmetics.js](server/monetization/cosmetics.js)) is `live` by default; `inventory` makes OpenVibe.Inventory the owner (ADR-054: `live.*` items issued by `service:live`, Live's item ids as aliases) through [inventory-client.js](server/monetization/inventory-client.js), and Live keeps only the catalog's renderers.## Commands@@@ -1,10 +1,31 @@/*** OpenVibe.Live — Cosmetics System* Global cosmetic items (name effects, particles, hats, voices), equipped globally for chat/overlay.++ *++ * Who owns them (ADR-054 §8): INVENTORY_AUTHORITY, read only here. `live` (the default until the switch) keeps them in++ * Live's user_cosmetics / user_equipped; `inventory` reads and writes them through OpenVibe.Inventory++ * (./inventory-client.js), where they are live.* items issued by service:live with Live's item ids as aliases. Live++ * keeps the catalog below (its renderers: CSS classes, particle glyphs, hats, speech presets) either way.* (The openvibe-quest game-item bridge, activate/return-to-game and its internal unlock route, was deleted with* X-Internal-Key in plan T2: the quest game no longer runs anywhere.)*/const db = require('../db/database');++const inventory = require('./inventory-client');++++/** 'live' or 'inventory' (ADR-054 §8). Anything else is 'live', with one warning. */++let warned = false;++function authority() {++ const v = String(process.env.INVENTORY_AUTHORITY || 'live').trim();++ if (v === 'live' || v === 'inventory') return v;++ if (!warned) { warned = true; console.warn(`[Cosmetics] INVENTORY_AUTHORITY=${JSON.stringify(v)} is not live or inventory; using live`); }++ return 'live';++}++const fromInventory = () => authority() === 'inventory';++const KIND = { name_effect: 'live.name_effect', particle: 'live.particle', hat: 'live.hat', voice: 'live.voice' };++/** An Inventory outage in a read: nothing, never an error in chat. */++const quietly = async (fn, fallback) => { try { return await fn(); } catch (err) { if (!(err instanceof inventory.InventoryUnavailable)) console.warn(`[Cosmetics] ${err.message}`); return fallback; } };++/** An Inventory refusal or outage in a write: the error the routes already show. */++const writeError = (err) => ({ error: err instanceof inventory.InventoryUnavailable ? 'Cosmetics are briefly unavailable; try again in a moment' : (err.message || 'Refused') });// ── Cosmetic Catalog (defines all cosmetics and their CSS/rendering data) ───const COSMETICS = {@@@ -100,12 +121,26 @@ const CATEGORY_SLOT = {// ── Get all unlocked cosmetics for a user ────────────────────async function getUnlocked(userId) {++ if (fromInventory()) {++ return await quietly(async () => {++ const subject = await inventory.subjectOf(userId);++ if (!subject) return [];++ const owned = await inventory.owned(subject);++ return [...owned].filter(([alias]) => COSMETICS[alias]).map(([alias, i]) => ({ item_id: alias, category: COSMETICS[alias].category, unlocked_at: i.acquired_at }));++ }, []);++ }const d = db.getDb();return await d.prepare('SELECT item_id, category, unlocked_at FROM user_cosmetics WHERE user_id = ?').all(userId);}// ── Get equipped cosmetics for a user ────────────────────────async function getEquipped(userId) {++ if (fromInventory()) {++ return await quietly(async () => {++ const subject = await inventory.subjectOf(userId);++ return subject ? await inventory.equipped(subject) : {};++ }, {});++ }const d = db.getDb();const rows = await d.prepare('SELECT slot, item_id FROM user_equipped WHERE user_id = ?').all(userId);const equipped = {};@@@ -138,6 +173,7 @@ async function getCosmeticProfile(userId) {// ── Check if user owns a cosmetic ────────────────────────────async function ownsCosmetic(userId, itemId) {++ if (fromInventory()) return (await getUnlocked(userId)).some((u) => u.item_id === itemId);const d = db.getDb();return !!await d.prepare('SELECT 1 FROM user_cosmetics WHERE user_id = ? AND item_id = ?').get(userId, itemId);}@@@ -147,6 +183,14 @@ async function unlockCosmetic(userId, itemId) {const cosmetic = COSMETICS[itemId];if (!cosmetic) return { error: 'Unknown cosmetic' };if (await ownsCosmetic(userId, itemId)) return { error: 'Already unlocked' };++ if (fromInventory()) {++ try {++ const subject = await inventory.subjectOf(userId);++ if (!subject) return { error: 'This account has no OpenVibe subject yet; sign in again' };++ await inventory.grant(subject, itemId, `live:${userId}:${itemId}`);++ return { success: true, item: cosmetic };++ } catch (err) { return writeError(err); }++ }const d = db.getDb();await d.prepare('INSERT INTO user_cosmetics (user_id, item_id, category) VALUES (?, ?, ?) ON CONFLICT DO NOTHING').run(userId, itemId, cosmetic.category);return { success: true, item: cosmetic };@@@ -154,6 +198,15 @@ async function unlockCosmetic(userId, itemId) {// ── Remove a cosmetic from collection ────────────────────────async function revokeCosmetic(userId, itemId) {++ if (fromInventory()) {++ try {++ const subject = await inventory.subjectOf(userId);++ const held = subject ? (await inventory.owned(subject)).get(itemId) : null;++ if (held) await inventory.revoke(held.instance_id, 'revoked on OpenVibe.Live');++ if (subject) inventory.forget(subject);++ return { success: true };++ } catch (err) { return writeError(err); }++ }const d = db.getDb();// Unequip first if equippedconst cosmetic = COSMETICS[itemId];@@@ -178,6 +231,15 @@ async function equipCosmetic(userId, itemId, { isAdmin = false } = {}) {}}const slot = CATEGORY_SLOT[cosmetic.category];++ if (fromInventory()) {++ try {++ const subject = await inventory.subjectOf(userId);++ const held = subject ? (await inventory.owned(subject)).get(itemId) : null;++ if (!held) return { error: 'You don\'t own this cosmetic' };++ await inventory.equip(subject, KIND[cosmetic.category], slot, held.instance_id);++ return { success: true, slot, item: cosmetic };++ } catch (err) { return writeError(err); }++ }const d = db.getDb();await d.prepare('INSERT INTO user_equipped (user_id, slot, item_id) VALUES (?, ?, ?) ON CONFLICT (user_id, slot) DO UPDATE SET item_id = excluded.item_id').run(userId, slot, itemId);return { success: true, slot, item: cosmetic };@@@ -186,6 +248,13 @@ async function equipCosmetic(userId, itemId, { isAdmin = false } = {}) {// ── Unequip a slot ───────────────────────────────────────────async function unequipSlot(userId, slot) {if (!['name_effect', 'particle', 'hat', 'voice'].includes(slot)) return { error: 'Invalid slot' };++ if (fromInventory()) {++ try {++ const subject = await inventory.subjectOf(userId);++ if (subject) await inventory.equip(subject, KIND[slot], slot, null);++ return { success: true, slot };++ } catch (err) { return writeError(err); }++ }const d = db.getDb();await d.prepare('DELETE FROM user_equipped WHERE user_id = ? AND slot = ?').run(userId, slot);return { success: true, slot };@@@ -214,6 +283,7 @@ async function getFullInventory(userId) {module.exports = {COSMETICS,++ authority,getUnlocked,getEquipped,getCosmeticProfile,nnew file mode 100644@@@ -0,0 +1,156 @@++/**++ * OpenVibe.Live → OpenVibe.Inventory (ADR-054 §8): Live's cosmetics are items issued by `service:live` in the++ * network-wide inventory. When INVENTORY_AUTHORITY=inventory, cosmetics.js reads and writes through this client.++ *++ * catalog() Live's definitions (alias = Live's item id ↔ itd_ id), cached 10 minutes++ * subjectOf(userId) a Live user's Network subject (linked_accounts, server/auth/identity-sync.js), cached++ * owned(subject) { alias → instance_id } of the live.* items the person owns++ * equipped(subject) { slot → alias } of what they wear, cached 30 s (chat and calls read it per message)++ * grant / revoke / equip the writes, acting for the person with Live's own token++ *++ * The token is Live's client-credentials token for audience openvibe.inventory (Network's grants to `live`:++ * inventory.item.read, .list, .grant, .consume, inventory.equip.manage). A service acting for a person sends++ * X-OV-Subject. In a restore drill (LIVE_DRILL) nothing is sent: reads answer empty and writes refuse.++ */++const { createServiceTokenClient } = require('openvibe-sdk/auth');++const identity = require('../auth/identity-sync');++++const AUDIENCE = 'openvibe.inventory';++const LIVE = 'service:live';++const TIMEOUT_MS = 5000;++const CATALOG_TTL_MS = 10 * 60 * 1000;++const EQUIPPED_TTL_MS = 30 * 1000;++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++++let tokens = null;++let catalogHit = null;++const equippedCache = new Map(); // subject → { at, value }++const subjects = new Map(); // Live user id → subject++let lastLog = null;++++function drill() { try { return require('../drill').enabled; } catch { return false; } }++const baseUrl = () => String(process.env.INVENTORY_URL || 'http://127.0.0.1:5030').replace(/\/+$/, '');++++class InventoryUnavailable extends Error {}++++function note(what, err) {++ const m = `OpenVibe.Inventory ${what}: ${(err && err.message) || err}`;++ if (m === lastLog) return; // once, until it changes++ lastLog = m;++ console.warn(`[Inventory] ${m}`);++}++++function serviceTokens() {++ if (tokens) return tokens;++ const clientSecret = process.env.OV_OAUTH_CLIENT_SECRET || '';++ if (!clientSecret) return null;++ const networkInternalUrl = String(process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000').replace(/\/+$/, '');++ tokens = createServiceTokenClient({ tokenUrl: `${networkInternalUrl}/oauth/token`, clientId: process.env.OV_OAUTH_CLIENT_ID || 'live', clientSecret, audience: AUDIENCE });++ return tokens;++}++++/** One call. → parsed JSON; a problem answer throws with its code; an outage throws InventoryUnavailable. */++async function call(method, path, { body, subject } = {}) {++ if (drill()) throw new InventoryUnavailable('restore drill: nothing is sent');++ const client = serviceTokens();++ if (!client) throw new InventoryUnavailable('OV_OAUTH_CLIENT_SECRET is not set');++ let res;++ try {++ res = await fetch(`${baseUrl()}/api/v1${path}`, {++ method,++ headers: {++ Accept: 'application/json', Authorization: `Bearer ${await client.getToken()}`,++ ...(subject ? { 'X-OV-Subject': subject } : {}), ...(body ? { 'Content-Type': 'application/json' } : {}),++ },++ body: body ? JSON.stringify(body) : undefined,++ signal: AbortSignal.timeout(TIMEOUT_MS),++ });++ } catch (err) { note(`${method} ${path.split('?')[0]}`, err); throw new InventoryUnavailable(err.message); }++ const out = await res.json().catch(() => ({}));++ if (res.status === 401) client.invalidate && client.invalidate();++ if (res.status >= 500) { note(`${method} ${path.split('?')[0]}`, `answered ${res.status}`); throw new InventoryUnavailable(`answered ${res.status}`); }++ if (!res.ok) throw Object.assign(new Error(out.detail || out.code || `answered ${res.status}`), { status: res.status, code: out.code });++ lastLog = null;++ return out;++}++++/** Live's definitions: { byAlias: Map(alias → definition), byId: Map(itd_ → alias) }. */++async function catalog() {++ if (catalogHit && Date.now() - catalogHit.at < CATALOG_TTL_MS) return catalogHit.value;++ const out = await call('GET', `/definitions?issuer=${encodeURIComponent(LIVE)}&limit=500`);++ const byAlias = new Map();++ const byId = new Map();++ for (const d of out.definitions || []) for (const alias of d.aliases || []) { byAlias.set(alias, d); byId.set(d.id, alias); }++ catalogHit = { at: Date.now(), value: { byAlias, byId } };++ return catalogHit.value;++}++++/** A Live user's Network subject, or null (an account without one holds no items). */++async function subjectOf(userId) {++ const id = Number(userId);++ if (subjects.has(id)) return subjects.get(id);++ const found = await identity.subjectOf(id);++ const s = SUBJECT_RE.test(String(found || '')) ? found : null;++ if (s) { subjects.set(id, s); if (subjects.size > 5000) subjects.delete(subjects.keys().next().value); }++ return s;++}++++/** { alias → { instance_id, acquired_at } } of the live.* items the person owns. */++async function owned(subject) {++ const { byId } = await catalog();++ const out = new Map();++ let cursor = null;++ for (let page = 0; page < 10; page++) {++ const r = await call('GET', `/me/items?limit=200${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`, { subject });++ for (const i of r.instances || []) {++ const alias = byId.get(i.definition_id);++ if (alias && i.state === 'owned' && !out.has(alias)) out.set(alias, { instance_id: i.id, acquired_at: i.acquired_at });++ }++ if (!r.next_cursor) break;++ cursor = r.next_cursor;++ }++ return out;++}++++/** { slot → alias } of the live.* items the person wears (cached 30 s). */++async function equipped(subject) {++ const hit = equippedCache.get(subject);++ if (hit && Date.now() - hit.at < EQUIPPED_TTL_MS) return hit.value;++ const { byId } = await catalog();++ const r = await call('GET', `/people/${encodeURIComponent(subject)}/equipped`);++ const value = {};++ for (const [key, v] of Object.entries(r.slots || {})) {++ const [kind, slot] = key.split(':');++ if (!kind.startsWith('live.')) continue;++ const alias = byId.get(v.definition_id);++ if (alias) value[slot] = alias;++ }++ equippedCache.set(subject, { at: Date.now(), value });++ if (equippedCache.size > 5000) equippedCache.delete(equippedCache.keys().next().value);++ return value;++}++++const forget = (subject) => equippedCache.delete(subject);++++async function grant(subject, alias, key) {++ const { byAlias } = await catalog();++ const d = byAlias.get(alias);++ if (!d) throw Object.assign(new Error('Unknown cosmetic'), { status: 404 });++ return (await call('POST', '/grants', { body: { definition_id: d.id, subject, idempotency_key: key, origin: 'granted', reason: 'given on OpenVibe.Live' } })).instance;++}++++async function revoke(instanceId, reason) {++ return (await call('POST', `/instances/${encodeURIComponent(instanceId)}/revoke`, { body: { reason: String(reason || 'revoked on OpenVibe.Live').slice(0, 200) } })).instance;++}++++async function equip(subject, kind, slot, instanceId) {++ forget(subject);++ const r = await call('PUT', '/me/equipped', { subject, body: { kind, slot, instance_id: instanceId } });++ forget(subject);++ return r;++}++++/** Tests only. */++function reset() { tokens = null; catalogHit = null; equippedCache.clear(); subjects.clear(); lastLog = null; }++++module.exports = { catalog, subjectOf, owned, equipped, grant, revoke, equip, forget, reset, InventoryUnavailable, AUDIENCE };nnew file mode 100644@@@ -0,0 +1,136 @@++'use strict';++++// ADR-054 §8: with INVENTORY_AUTHORITY=inventory, Live's cosmetics live in OpenVibe.Inventory. A stand-in Inventory and++// Network check that Live reads a person's items and slots there (by their Network subject, with Live's own token and++// X-OV-Subject), grants, equips, unequips and revokes there, never touches its own user_cosmetics / user_equipped, shows++// nothing (rather than an error) when Inventory is down, and refuses writes with a clear message. Unset, Live keeps++// its own tables.++++const assert = require('assert');++const http = require('http');++++console.log = () => {};++console.warn = () => {};++++const A = 'usr_01JZ0000000000000000000AAA';++const DEFS = [++ { id: 'itd_01JZ00000000000000000001R1', kind: 'live.name_effect', aliases: ['fx_rainbow'] },++ { id: 'itd_01JZ00000000000000000001R2', kind: 'live.hat', aliases: ['hat_crown'] },++ { id: 'itd_01JZ00000000000000000001R3', kind: 'live.particle', aliases: ['px_sparkle'] },++];++const state = { instances: [], slots: {}, calls: [], down: false };++let seq = 0;++++function stub() {++ return http.createServer((req, res) => {++ const chunks = [];++ req.on('data', (c) => chunks.push(c));++ req.on('end', () => {++ const body = chunks.length && /json/.test(String(req.headers['content-type'] || '')) ? JSON.parse(Buffer.concat(chunks).toString()) : null;++ const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };++ const u = new URL(req.url, 'http://x');++ if (u.pathname === '/oauth/token') return json(200, { access_token: 'tok_live', token_type: 'Bearer', expires_in: 300 });++ state.calls.push({ method: req.method, path: u.pathname, subject: req.headers['x-ov-subject'] || null, auth: req.headers.authorization });++ if (state.down) return json(503, { code: 'down' });++ if (u.pathname === '/api/v1/definitions') return json(200, { definitions: DEFS });++ if (u.pathname === '/api/v1/me/items') return json(200, { subject: req.headers['x-ov-subject'], instances: state.instances.filter((i) => i.owner === req.headers['x-ov-subject']), definitions: {}, next_cursor: null });++ if (u.pathname === `/api/v1/people/${A}/equipped`) return json(200, { subject: A, slots: state.slots, updated_at: new Date().toISOString() });++ if (u.pathname === '/api/v1/grants' && req.method === 'POST') {++ const inst = { id: `inv_01JZ0000000000000000000${String(++seq).padStart(3, '0')}`.replace(/0(?=\d{3}$)/, '1'), definition_id: body.definition_id, owner: body.subject, origin: 'granted', state: 'owned', acquired_at: new Date().toISOString() };++ state.instances.push(inst);++ return json(201, { instance: inst, created: true });++ }++ if (u.pathname === '/api/v1/me/equipped' && req.method === 'PUT') {++ const key = `${body.kind}:${body.slot}`;++ if (body.instance_id === null) delete state.slots[key];++ else { const inst = state.instances.find((i) => i.id === body.instance_id); state.slots[key] = { instance_id: inst.id, definition_id: inst.definition_id }; }++ return json(200, { subject: A, slots: state.slots, updated_at: new Date().toISOString() });++ }++ const m = u.pathname.match(/^\/api\/v1\/instances\/([^/]+)\/revoke$/);++ if (m) { const inst = state.instances.find((i) => i.id === m[1]); inst.state = 'revoked'; for (const [k, v] of Object.entries(state.slots)) if (v.instance_id === inst.id) delete state.slots[k]; return json(200, { instance: inst }); }++ return json(404, { code: 'route.not_found' });++ });++ });++}++++(async () => {++ const server = stub();++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ const url = `http://127.0.0.1:${server.address().port}`;++ process.env.INVENTORY_URL = url;++ process.env.OV_NETWORK_INTERNAL_URL = url;++ process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';++ const db = require('../server/db/database');++ await db.initDb();++ const d = db.getDb();++ await d.prepare("INSERT INTO users (id, username, display_name, password_hash) OVERRIDING SYSTEM VALUE VALUES (70, 'ana', 'Ana', '$sso$'), (71, 'nosub', 'No Sub', '$sso$')").run();++ // Live learns a person's subject from their Network token (server/auth/identity-sync.js noteSubject).++ await d.prepare(`INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (70, 'network', '9001', '${A}'), (71, 'network', '9002', NULL)`).run();++ const cosmetics = require('../server/monetization/cosmetics');++ const inventory = require('../server/monetization/inventory-client');++ const ownRows = async () => Number((await d.prepare('SELECT count(*) AS n FROM user_cosmetics').get()).n) + Number((await d.prepare('SELECT count(*) AS n FROM user_equipped').get()).n);++++ try {++ // Unset: Live's own tables.++ delete process.env.INVENTORY_AUTHORITY;++ assert.strictEqual(cosmetics.authority(), 'live');++ assert.deepStrictEqual(await cosmetics.unlockCosmetic(70, 'fx_fire'), { success: true, item: cosmetics.COSMETICS.fx_fire });++ assert.strictEqual(await ownRows(), 1, 'live keeps its own row');++ await d.prepare('DELETE FROM user_cosmetics').run();++ assert.strictEqual(state.calls.length, 0, 'nothing went to Inventory');++++ process.env.INVENTORY_AUTHORITY = 'inventory';++ assert.strictEqual(cosmetics.authority(), 'inventory');++++ // Unlock and equip: in Inventory, for the person's subject, with Live's token.++ assert.strictEqual((await cosmetics.unlockCosmetic(70, 'fx_rainbow')).success, true);++ assert.strictEqual((await cosmetics.unlockCosmetic(70, 'fx_rainbow')).error, 'Already unlocked');++ assert.strictEqual((await cosmetics.unlockCosmetic(70, 'hat_crown')).success, true);++ const grant = state.calls.find((c) => c.path === '/api/v1/grants');++ assert.strictEqual(grant.auth, 'Bearer tok_live');++ assert.deepStrictEqual((await cosmetics.getUnlocked(70)).map((u) => u.item_id).sort(), ['fx_rainbow', 'hat_crown']);++ assert.strictEqual((await cosmetics.equipCosmetic(70, 'fx_rainbow')).success, true);++ assert.strictEqual((await cosmetics.equipCosmetic(70, 'px_sparkle')).error, 'You don\'t own this cosmetic');++ assert.strictEqual(state.calls.filter((c) => c.path === '/api/v1/me/equipped').every((c) => c.subject === A), true, 'acting for her with X-OV-Subject');++ const profile = await cosmetics.getCosmeticProfile(70);++ assert.deepStrictEqual(profile.nameFX, { itemId: 'fx_rainbow', cssClass: 'name-fx-rainbow' });++ assert.strictEqual(profile.hatFX, undefined);++ const full = await cosmetics.getFullInventory(70);++ assert.deepStrictEqual([full.categories.name_effect.map((c) => [c.itemId, c.equipped]), full.categories.hat.length], [[['fx_rainbow', true]], 1]);++++ // An admin equips something they do not own: granted, then equipped.++ assert.strictEqual((await cosmetics.equipCosmetic(70, 'px_sparkle', { isAdmin: true })).success, true);++ inventory.forget(A);++ assert.strictEqual((await cosmetics.getEquipped(70)).particle, 'px_sparkle');++++ // Unequip and revoke.++ assert.strictEqual((await cosmetics.unequipSlot(70, 'name_effect')).success, true);++ inventory.forget(A);++ assert.strictEqual((await cosmetics.getEquipped(70)).name_effect, undefined);++ assert.strictEqual((await cosmetics.revokeCosmetic(70, 'hat_crown')).success, true);++ assert.ok(!(await cosmetics.getUnlocked(70)).some((u) => u.item_id === 'hat_crown'));++++ // Live's own tables are not written in this mode.++ assert.strictEqual(await ownRows(), 0, 'no row in user_cosmetics or user_equipped');++++ // A Live account without a Network subject holds nothing, and cannot be given anything.++ assert.deepStrictEqual(await cosmetics.getUnlocked(71), []);++ assert.match((await cosmetics.unlockCosmetic(71, 'fx_ice')).error, /subject/);++++ // Inventory down: reads show nothing (never a chat error); writes say so.++ state.down = true;++ inventory.forget(A);++ assert.deepStrictEqual(await cosmetics.getCosmeticProfile(70), {});++ assert.match((await cosmetics.equipCosmetic(70, 'px_sparkle')).error, /unavailable|own/i);++ state.down = false;++++ // An unknown value falls back to live.++ process.env.INVENTORY_AUTHORITY = 'nonsense';++ assert.strictEqual(cosmetics.authority(), 'live');++ process.stdout.write('cosmetics through OpenVibe.Inventory: all checks passed\n');++ } finally {++ server.close();++ await db.close?.();++ }++ process.exit(0);++})().catch((err) => { process.stderr.write(`${err.stack || err}\n`); process.exit(1); });@@@ -345,6 +345,7 @@ async function check(name, fn) {'server/pastes-client.js': 'OpenVibe.Community (configured)','server/comments-client.js': 'OpenVibe.Community (configured)','server/chat/moderation-client.js': 'OpenVibe.Chat internal read API (configured)',++ 'server/monetization/inventory-client.js': 'OpenVibe.Inventory API for Live cosmetics (configured INVENTORY_URL)','server/chat/insight-client.js': 'OpenVibe.Chat public chat-AI reads (configured OV_CHAT_INTERNAL_URL, fixed paths, ids validated)','server/chat/chat-client.js': 'OpenVibe.Chat typed ingress (configured OV_CHAT_INTERNAL_URL, fixed /internal/chat/* paths)','server/chat/chat-delivery.js': 'OpenVibe.Tools anon resolve (configured)',
@@ -196,4 +196,11 @@ OV_CHAT_INTERNAL_URL=http://127.0.0.1:4400
@@ -196,4 +196,11 @@ OV_CHAT_INTERNAL_URL=http://127.0.0.1:4400
#BILLING_AUTHORITY=billing
#BILLING_AUTHORITY=billing
OV_BILLING_INTERNAL_URL=http://127.0.0.1:4600
OV_BILLING_INTERNAL_URL=http://127.0.0.1:4600
OV_BILLING_PUBLIC_URL=https://billing.openvibe.network
OV_BILLING_PUBLIC_URL=https://billing.openvibe.network
+
+# Cosmetics authority (ADR-054 §8, read only in server/monetization/cosmetics.js). live (default): Live's own
+# user_cosmetics / user_equipped. inventory: OpenVibe.Inventory owns them (live.* items issued by service:live, Live's
+# item ids as aliases); Live reads and writes with its service token (audience openvibe.inventory) acting for the
+# person, and keeps only the renderers. Switch after OpenVibe.Inventory's scripts/import-live.js --verify passes.
+#INVENTORY_AUTHORITY=inventory
+#INVENTORY_URL=http://127.0.0.1:5030
# OV_BILLING_TIMEOUT_MS=8000
# OV_BILLING_TIMEOUT_MS=8000
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
Self-hosted live streaming platform — Node.js/Express monolith, vanilla JS SPA frontend, PostgreSQL (`openvibe-sdk/db`; embedded PGlite in development and tests; on PostgreSQL since 2026-10-08). Part of the **OpenVibe network** with SSO via openvibe.network. See [README.md](README.md) for features and [docs/architecture.md](docs/architecture.md) for system design.
Self-hosted live streaming platform — Node.js/Express monolith, vanilla JS SPA frontend, PostgreSQL (`openvibe-sdk/db`; embedded PGlite in development and tests; on PostgreSQL since 2026-10-08). Part of the **OpenVibe network** with SSO via openvibe.network. See [README.md](README.md) for features and [docs/architecture.md](docs/architecture.md) for system design.
-**Network context:** SSO/OAuth2 + the OpenCoins wallet come from **OpenVibe.Network** (`server/monetization/wallet-client.js`). The media subsystem (VODs/clips/pastes/thumbnails/files) lives in **OpenVibe.Media** — Live talks to it via [server/media-client.js](server/media-client.js), the thin proxy routers in [server/media-proxy/](server/media-proxy/), and the Media-backed recorder [server/streaming/recorder.js](server/streaming/recorder.js). Media completion events arrive over OpenVibe.Events at `POST /internal/media-events` (`MEDIA_EVENTS_AUTHORITY`; the older `POST /internal/media-webhook` stays during the transition). The legacy local `vods`/`clips`/`pastes`/`paste_likes`/`paste_comments` tables are FROZEN: never read or write them (`test/frozen-tables.test.js` enforces it; ask Media or Community through `server/media-proxy/lookups.js`, `media-client.js`, `pastes-client.js`); VOD/clip comments are OpenVibe.Community threads ([server/comments-client.js](server/comments-client.js)) and the local `comments` table is READ-ONLY too; Chat owns every chat table — the staged set (`channel_moderators`, `channel_moderation_settings`, `emotes`, `user_tags`, `chat_ai_summaries`, `chat_timeline_events`; Live reads those only through `server/chat/moderation-client.js`, capability `chat.moderation.read`, cached 30 s, and the summaries through `server/chat/insight-client.js`, cached 60 s) and the twelve the chat cutover imports covered (`chat_messages`, the DM tables, `tts_voice_overrides`, `channel_sounds`, `relay_users`, `hidden_relay_users`, `pending_ip_messages`, `stream_first_chats`, `moderation_actions`); Live keeps no copy of any of them since 2026-10-05 — chat stats, queues, history, first-chat and DM-block state all come through `server/chat/chat-reads.js`, and the twelve tables are dropped at boot by migration `007_drop_chat_tables` (`test/chat-tables-dropped.test.js` guards that nothing under `server/` names them); Live-owned AI/transcript state for Media-hosted content lives in `vod_ai_state`/`clip_ai_state`. See ../CONTRACTS.md for the binding inter-service contracts. **Money:** `BILLING_AUTHORITY` (read only in [server/monetization/money-authority.js](server/monetization/money-authority.js)) is `live` by default; `billing` makes OpenVibe.Billing the ledger ([billing-client.js](server/monetization/billing-client.js), [billing-actions.js](server/monetization/billing-actions.js)) and Live's `openvibe_bucks_*`/`transactions`/`payment_orders`/`subscriptions` read-only (a database-level tripwire throws). The owner-only `money_writes_frozen` flag (`/api/admin/money`) refuses money actions in both modes.
+**Network context:** SSO/OAuth2 + the OpenCoins wallet come from **OpenVibe.Network** (`server/monetization/wallet-client.js`). The media subsystem (VODs/clips/pastes/thumbnails/files) lives in **OpenVibe.Media** — Live talks to it via [server/media-client.js](server/media-client.js), the thin proxy routers in [server/media-proxy/](server/media-proxy/), and the Media-backed recorder [server/streaming/recorder.js](server/streaming/recorder.js). Media completion events arrive over OpenVibe.Events at `POST /internal/media-events` (`MEDIA_EVENTS_AUTHORITY`; the older `POST /internal/media-webhook` stays during the transition). The legacy local `vods`/`clips`/`pastes`/`paste_likes`/`paste_comments` tables are FROZEN: never read or write them (`test/frozen-tables.test.js` enforces it; ask Media or Community through `server/media-proxy/lookups.js`, `media-client.js`, `pastes-client.js`); VOD/clip comments are OpenVibe.Community threads ([server/comments-client.js](server/comments-client.js)) and the local `comments` table is READ-ONLY too; Chat owns every chat table — the staged set (`channel_moderators`, `channel_moderation_settings`, `emotes`, `user_tags`, `chat_ai_summaries`, `chat_timeline_events`; Live reads those only through `server/chat/moderation-client.js`, capability `chat.moderation.read`, cached 30 s, and the summaries through `server/chat/insight-client.js`, cached 60 s) and the twelve the chat cutover imports covered (`chat_messages`, the DM tables, `tts_voice_overrides`, `channel_sounds`, `relay_users`, `hidden_relay_users`, `pending_ip_messages`, `stream_first_chats`, `moderation_actions`); Live keeps no copy of any of them since 2026-10-05 — chat stats, queues, history, first-chat and DM-block state all come through `server/chat/chat-reads.js`, and the twelve tables are dropped at boot by migration `007_drop_chat_tables` (`test/chat-tables-dropped.test.js` guards that nothing under `server/` names them); Live-owned AI/transcript state for Media-hosted content lives in `vod_ai_state`/`clip_ai_state`. See ../CONTRACTS.md for the binding inter-service contracts. **Money:** `BILLING_AUTHORITY` (read only in [server/monetization/money-authority.js](server/monetization/money-authority.js)) is `live` by default; `billing` makes OpenVibe.Billing the ledger ([billing-client.js](server/monetization/billing-client.js), [billing-actions.js](server/monetization/billing-actions.js)) and Live's `openvibe_bucks_*`/`transactions`/`payment_orders`/`subscriptions` read-only (a database-level tripwire throws). The owner-only `money_writes_frozen` flag (`/api/admin/money`) refuses money actions in both modes. **Cosmetics:** `INVENTORY_AUTHORITY` (read only in [server/monetization/cosmetics.js](server/monetization/cosmetics.js)) is `live` by default; `inventory` makes OpenVibe.Inventory the owner (ADR-054: `live.*` items issued by `service:live`, Live's item ids as aliases) through [inventory-client.js](server/monetization/inventory-client.js), and Live keeps only the catalog's renderers.
## Commands
## Commands
@@ -1,10 +1,31 @@
@@ -1,10 +1,31 @@
/**
/**
* OpenVibe.Live — Cosmetics System
* OpenVibe.Live — Cosmetics System
* Global cosmetic items (name effects, particles, hats, voices), equipped globally for chat/overlay.
* Global cosmetic items (name effects, particles, hats, voices), equipped globally for chat/overlay.
+ *
+ * Who owns them (ADR-054 §8): INVENTORY_AUTHORITY, read only here. `live` (the default until the switch) keeps them in
+ * Live's user_cosmetics / user_equipped; `inventory` reads and writes them through OpenVibe.Inventory
+ * (./inventory-client.js), where they are live.* items issued by service:live with Live's item ids as aliases. Live
+ * keeps the catalog below (its renderers: CSS classes, particle glyphs, hats, speech presets) either way.
* (The openvibe-quest game-item bridge, activate/return-to-game and its internal unlock route, was deleted with
* (The openvibe-quest game-item bridge, activate/return-to-game and its internal unlock route, was deleted with
* X-Internal-Key in plan T2: the quest game no longer runs anywhere.)
* X-Internal-Key in plan T2: the quest game no longer runs anywhere.)
*/
*/
const db = require('../db/database');
const db = require('../db/database');
+const inventory = require('./inventory-client');
+
+/** 'live' or 'inventory' (ADR-054 §8). Anything else is 'live', with one warning. */
+let warned = false;
+function authority() {
+ const v = String(process.env.INVENTORY_AUTHORITY || 'live').trim();
+ if (v === 'live' || v === 'inventory') return v;
+ if (!warned) { warned = true; console.warn(`[Cosmetics] INVENTORY_AUTHORITY=${JSON.stringify(v)} is not live or inventory; using live`); }
+ return 'live';
+}
+const fromInventory = () => authority() === 'inventory';
+const KIND = { name_effect: 'live.name_effect', particle: 'live.particle', hat: 'live.hat', voice: 'live.voice' };
+/** An Inventory outage in a read: nothing, never an error in chat. */
+const quietly = async (fn, fallback) => { try { return await fn(); } catch (err) { if (!(err instanceof inventory.InventoryUnavailable)) console.warn(`[Cosmetics] ${err.message}`); return fallback; } };
+/** An Inventory refusal or outage in a write: the error the routes already show. */
+const writeError = (err) => ({ error: err instanceof inventory.InventoryUnavailable ? 'Cosmetics are briefly unavailable; try again in a moment' : (err.message || 'Refused') });
// ── Cosmetic Catalog (defines all cosmetics and their CSS/rendering data) ───
// ── Cosmetic Catalog (defines all cosmetics and their CSS/rendering data) ───
const COSMETICS = {
const COSMETICS = {
@@ -100,12 +121,26 @@ const CATEGORY_SLOT = {
@@ -100,12 +121,26 @@ const CATEGORY_SLOT = {
// ── Get all unlocked cosmetics for a user ────────────────────
// ── Get all unlocked cosmetics for a user ────────────────────
async function getUnlocked(userId) {
async function getUnlocked(userId) {
+ if (fromInventory()) {
+ return await quietly(async () => {
+ const subject = await inventory.subjectOf(userId);
+ if (!subject) return [];
+ const owned = await inventory.owned(subject);
+ return [...owned].filter(([alias]) => COSMETICS[alias]).map(([alias, i]) => ({ item_id: alias, category: COSMETICS[alias].category, unlocked_at: i.acquired_at }));
+ }, []);
+ }
const d = db.getDb();
const d = db.getDb();
return await d.prepare('SELECT item_id, category, unlocked_at FROM user_cosmetics WHERE user_id = ?').all(userId);
return await d.prepare('SELECT item_id, category, unlocked_at FROM user_cosmetics WHERE user_id = ?').all(userId);
}
}
// ── Get equipped cosmetics for a user ────────────────────────
// ── Get equipped cosmetics for a user ────────────────────────
async function getEquipped(userId) {
async function getEquipped(userId) {
+ if (fromInventory()) {
+ return await quietly(async () => {
+ const subject = await inventory.subjectOf(userId);
+ return subject ? await inventory.equipped(subject) : {};
+ }, {});
+ }
const d = db.getDb();
const d = db.getDb();
const rows = await d.prepare('SELECT slot, item_id FROM user_equipped WHERE user_id = ?').all(userId);
const rows = await d.prepare('SELECT slot, item_id FROM user_equipped WHERE user_id = ?').all(userId);
const equipped = {};
const equipped = {};
@@ -138,6 +173,7 @@ async function getCosmeticProfile(userId) {
@@ -138,6 +173,7 @@ async function getCosmeticProfile(userId) {
// ── Check if user owns a cosmetic ────────────────────────────
// ── Check if user owns a cosmetic ────────────────────────────
async function ownsCosmetic(userId, itemId) {
async function ownsCosmetic(userId, itemId) {
+ if (fromInventory()) return (await getUnlocked(userId)).some((u) => u.item_id === itemId);
const d = db.getDb();
const d = db.getDb();
return !!await d.prepare('SELECT 1 FROM user_cosmetics WHERE user_id = ? AND item_id = ?').get(userId, itemId);
return !!await d.prepare('SELECT 1 FROM user_cosmetics WHERE user_id = ? AND item_id = ?').get(userId, itemId);
}
}
@@ -147,6 +183,14 @@ async function unlockCosmetic(userId, itemId) {
@@ -147,6 +183,14 @@ async function unlockCosmetic(userId, itemId) {
const cosmetic = COSMETICS[itemId];
const cosmetic = COSMETICS[itemId];
if (!cosmetic) return { error: 'Unknown cosmetic' };
if (!cosmetic) return { error: 'Unknown cosmetic' };
if (await ownsCosmetic(userId, itemId)) return { error: 'Already unlocked' };
if (await ownsCosmetic(userId, itemId)) return { error: 'Already unlocked' };
+ if (fromInventory()) {
+ try {
+ const subject = await inventory.subjectOf(userId);
+ if (!subject) return { error: 'This account has no OpenVibe subject yet; sign in again' };
+ await inventory.grant(subject, itemId, `live:${userId}:${itemId}`);
+ return { success: true, item: cosmetic };
+ } catch (err) { return writeError(err); }
+ }
const d = db.getDb();
const d = db.getDb();
await d.prepare('INSERT INTO user_cosmetics (user_id, item_id, category) VALUES (?, ?, ?) ON CONFLICT DO NOTHING').run(userId, itemId, cosmetic.category);
await d.prepare('INSERT INTO user_cosmetics (user_id, item_id, category) VALUES (?, ?, ?) ON CONFLICT DO NOTHING').run(userId, itemId, cosmetic.category);
return { success: true, item: cosmetic };
return { success: true, item: cosmetic };
@@ -154,6 +198,15 @@ async function unlockCosmetic(userId, itemId) {
@@ -154,6 +198,15 @@ async function unlockCosmetic(userId, itemId) {
// ── Remove a cosmetic from collection ────────────────────────
// ── Remove a cosmetic from collection ────────────────────────
async function revokeCosmetic(userId, itemId) {
async function revokeCosmetic(userId, itemId) {
+ if (fromInventory()) {
+ try {
+ const subject = await inventory.subjectOf(userId);
+ const held = subject ? (await inventory.owned(subject)).get(itemId) : null;
+ if (held) await inventory.revoke(held.instance_id, 'revoked on OpenVibe.Live');
+ if (subject) inventory.forget(subject);
+ return { success: true };
+ } catch (err) { return writeError(err); }
+ }
const d = db.getDb();
const d = db.getDb();
// Unequip first if equipped
// Unequip first if equipped
const cosmetic = COSMETICS[itemId];
const cosmetic = COSMETICS[itemId];
@@ -178,6 +231,15 @@ async function equipCosmetic(userId, itemId, { isAdmin = false } = {}) {
@@ -178,6 +231,15 @@ async function equipCosmetic(userId, itemId, { isAdmin = false } = {}) {
}
}
}
}
const slot = CATEGORY_SLOT[cosmetic.category];
const slot = CATEGORY_SLOT[cosmetic.category];
+ if (fromInventory()) {
+ try {
+ const subject = await inventory.subjectOf(userId);
+ const held = subject ? (await inventory.owned(subject)).get(itemId) : null;
+ if (!held) return { error: 'You don\'t own this cosmetic' };
+ await inventory.equip(subject, KIND[cosmetic.category], slot, held.instance_id);
+ return { success: true, slot, item: cosmetic };
+ } catch (err) { return writeError(err); }
+ }
const d = db.getDb();
const d = db.getDb();
await d.prepare('INSERT INTO user_equipped (user_id, slot, item_id) VALUES (?, ?, ?) ON CONFLICT (user_id, slot) DO UPDATE SET item_id = excluded.item_id').run(userId, slot, itemId);
await d.prepare('INSERT INTO user_equipped (user_id, slot, item_id) VALUES (?, ?, ?) ON CONFLICT (user_id, slot) DO UPDATE SET item_id = excluded.item_id').run(userId, slot, itemId);
return { success: true, slot, item: cosmetic };
return { success: true, slot, item: cosmetic };
@@ -186,6 +248,13 @@ async function equipCosmetic(userId, itemId, { isAdmin = false } = {}) {
@@ -186,6 +248,13 @@ async function equipCosmetic(userId, itemId, { isAdmin = false } = {}) {
// ── Unequip a slot ───────────────────────────────────────────
// ── Unequip a slot ───────────────────────────────────────────
async function unequipSlot(userId, slot) {
async function unequipSlot(userId, slot) {
if (!['name_effect', 'particle', 'hat', 'voice'].includes(slot)) return { error: 'Invalid slot' };
if (!['name_effect', 'particle', 'hat', 'voice'].includes(slot)) return { error: 'Invalid slot' };
+ if (fromInventory()) {
+ try {
+ const subject = await inventory.subjectOf(userId);
+ if (subject) await inventory.equip(subject, KIND[slot], slot, null);
+ return { success: true, slot };
+ } catch (err) { return writeError(err); }
+ }
const d = db.getDb();
const d = db.getDb();
await d.prepare('DELETE FROM user_equipped WHERE user_id = ? AND slot = ?').run(userId, slot);
await d.prepare('DELETE FROM user_equipped WHERE user_id = ? AND slot = ?').run(userId, slot);
return { success: true, slot };
return { success: true, slot };
@@ -214,6 +283,7 @@ async function getFullInventory(userId) {
@@ -214,6 +283,7 @@ async function getFullInventory(userId) {
module.exports = {
module.exports = {
COSMETICS,
COSMETICS,
+ authority,
getUnlocked,
getUnlocked,
getEquipped,
getEquipped,
getCosmeticProfile,
getCosmeticProfile,
new file mode 100644
new file mode 100644
@@ -0,0 +1,156 @@
@@ -0,0 +1,156 @@
+/**
+ * OpenVibe.Live → OpenVibe.Inventory (ADR-054 §8): Live's cosmetics are items issued by `service:live` in the
+ * network-wide inventory. When INVENTORY_AUTHORITY=inventory, cosmetics.js reads and writes through this client.
+ *
+ * catalog() Live's definitions (alias = Live's item id ↔ itd_ id), cached 10 minutes
+ * subjectOf(userId) a Live user's Network subject (linked_accounts, server/auth/identity-sync.js), cached
+ * owned(subject) { alias → instance_id } of the live.* items the person owns
+ * equipped(subject) { slot → alias } of what they wear, cached 30 s (chat and calls read it per message)
+ * grant / revoke / equip the writes, acting for the person with Live's own token
+ *
+ * The token is Live's client-credentials token for audience openvibe.inventory (Network's grants to `live`:
+ * inventory.item.read, .list, .grant, .consume, inventory.equip.manage). A service acting for a person sends
+ * X-OV-Subject. In a restore drill (LIVE_DRILL) nothing is sent: reads answer empty and writes refuse.
+ */
+const { createServiceTokenClient } = require('openvibe-sdk/auth');
+const identity = require('../auth/identity-sync');
+
+const AUDIENCE = 'openvibe.inventory';
+const LIVE = 'service:live';
+const TIMEOUT_MS = 5000;
+const CATALOG_TTL_MS = 10 * 60 * 1000;
+const EQUIPPED_TTL_MS = 30 * 1000;
+const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+
+let tokens = null;
+let catalogHit = null;
+const equippedCache = new Map(); // subject → { at, value }
+const subjects = new Map(); // Live user id → subject
+let lastLog = null;
+
+function drill() { try { return require('../drill').enabled; } catch { return false; } }
+const baseUrl = () => String(process.env.INVENTORY_URL || 'http://127.0.0.1:5030').replace(/\/+$/, '');
+
+class InventoryUnavailable extends Error {}
+
+function note(what, err) {
+ const m = `OpenVibe.Inventory ${what}: ${(err && err.message) || err}`;
+ if (m === lastLog) return; // once, until it changes
+ lastLog = m;
+ console.warn(`[Inventory] ${m}`);
+}
+
+function serviceTokens() {
+ if (tokens) return tokens;
+ const clientSecret = process.env.OV_OAUTH_CLIENT_SECRET || '';
+ if (!clientSecret) return null;
+ const networkInternalUrl = String(process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000').replace(/\/+$/, '');
+ tokens = createServiceTokenClient({ tokenUrl: `${networkInternalUrl}/oauth/token`, clientId: process.env.OV_OAUTH_CLIENT_ID || 'live', clientSecret, audience: AUDIENCE });
+ return tokens;
+}
+
+/** One call. → parsed JSON; a problem answer throws with its code; an outage throws InventoryUnavailable. */
+async function call(method, path, { body, subject } = {}) {
+ if (drill()) throw new InventoryUnavailable('restore drill: nothing is sent');
+ const client = serviceTokens();
+ if (!client) throw new InventoryUnavailable('OV_OAUTH_CLIENT_SECRET is not set');
+ let res;
+ try {
+ res = await fetch(`${baseUrl()}/api/v1${path}`, {
+ method,
+ headers: {
+ Accept: 'application/json', Authorization: `Bearer ${await client.getToken()}`,
+ ...(subject ? { 'X-OV-Subject': subject } : {}), ...(body ? { 'Content-Type': 'application/json' } : {}),
+ },
+ body: body ? JSON.stringify(body) : undefined,
+ signal: AbortSignal.timeout(TIMEOUT_MS),
+ });
+ } catch (err) { note(`${method} ${path.split('?')[0]}`, err); throw new InventoryUnavailable(err.message); }
+ const out = await res.json().catch(() => ({}));
+ if (res.status === 401) client.invalidate && client.invalidate();
+ if (res.status >= 500) { note(`${method} ${path.split('?')[0]}`, `answered ${res.status}`); throw new InventoryUnavailable(`answered ${res.status}`); }
+ if (!res.ok) throw Object.assign(new Error(out.detail || out.code || `answered ${res.status}`), { status: res.status, code: out.code });
+ lastLog = null;
+ return out;
+}
+
+/** Live's definitions: { byAlias: Map(alias → definition), byId: Map(itd_ → alias) }. */
+async function catalog() {
+ if (catalogHit && Date.now() - catalogHit.at < CATALOG_TTL_MS) return catalogHit.value;
+ const out = await call('GET', `/definitions?issuer=${encodeURIComponent(LIVE)}&limit=500`);
+ const byAlias = new Map();
+ const byId = new Map();
+ for (const d of out.definitions || []) for (const alias of d.aliases || []) { byAlias.set(alias, d); byId.set(d.id, alias); }
+ catalogHit = { at: Date.now(), value: { byAlias, byId } };
+ return catalogHit.value;
+}
+
+/** A Live user's Network subject, or null (an account without one holds no items). */
+async function subjectOf(userId) {
+ const id = Number(userId);
+ if (subjects.has(id)) return subjects.get(id);
+ const found = await identity.subjectOf(id);
+ const s = SUBJECT_RE.test(String(found || '')) ? found : null;
+ if (s) { subjects.set(id, s); if (subjects.size > 5000) subjects.delete(subjects.keys().next().value); }
+ return s;
+}
+
+/** { alias → { instance_id, acquired_at } } of the live.* items the person owns. */
+async function owned(subject) {
+ const { byId } = await catalog();
+ const out = new Map();
+ let cursor = null;
+ for (let page = 0; page < 10; page++) {
+ const r = await call('GET', `/me/items?limit=200${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`, { subject });
+ for (const i of r.instances || []) {
+ const alias = byId.get(i.definition_id);
+ if (alias && i.state === 'owned' && !out.has(alias)) out.set(alias, { instance_id: i.id, acquired_at: i.acquired_at });
+ }
+ if (!r.next_cursor) break;
+ cursor = r.next_cursor;
+ }
+ return out;
+}
+
+/** { slot → alias } of the live.* items the person wears (cached 30 s). */
+async function equipped(subject) {
+ const hit = equippedCache.get(subject);
+ if (hit && Date.now() - hit.at < EQUIPPED_TTL_MS) return hit.value;
+ const { byId } = await catalog();
+ const r = await call('GET', `/people/${encodeURIComponent(subject)}/equipped`);
+ const value = {};
+ for (const [key, v] of Object.entries(r.slots || {})) {
+ const [kind, slot] = key.split(':');
+ if (!kind.startsWith('live.')) continue;
+ const alias = byId.get(v.definition_id);
+ if (alias) value[slot] = alias;
+ }
+ equippedCache.set(subject, { at: Date.now(), value });
+ if (equippedCache.size > 5000) equippedCache.delete(equippedCache.keys().next().value);
+ return value;
+}
+
+const forget = (subject) => equippedCache.delete(subject);
+
+async function grant(subject, alias, key) {
+ const { byAlias } = await catalog();
+ const d = byAlias.get(alias);
+ if (!d) throw Object.assign(new Error('Unknown cosmetic'), { status: 404 });
+ return (await call('POST', '/grants', { body: { definition_id: d.id, subject, idempotency_key: key, origin: 'granted', reason: 'given on OpenVibe.Live' } })).instance;
+}
+
+async function revoke(instanceId, reason) {
+ return (await call('POST', `/instances/${encodeURIComponent(instanceId)}/revoke`, { body: { reason: String(reason || 'revoked on OpenVibe.Live').slice(0, 200) } })).instance;
+}
+
+async function equip(subject, kind, slot, instanceId) {
+ forget(subject);
+ const r = await call('PUT', '/me/equipped', { subject, body: { kind, slot, instance_id: instanceId } });
+ forget(subject);
+ return r;
+}
+
+/** Tests only. */
+function reset() { tokens = null; catalogHit = null; equippedCache.clear(); subjects.clear(); lastLog = null; }
+
+module.exports = { catalog, subjectOf, owned, equipped, grant, revoke, equip, forget, reset, InventoryUnavailable, AUDIENCE };
new file mode 100644
new file mode 100644
@@ -0,0 +1,136 @@
@@ -0,0 +1,136 @@
+'use strict';
+
+// ADR-054 §8: with INVENTORY_AUTHORITY=inventory, Live's cosmetics live in OpenVibe.Inventory. A stand-in Inventory and
+// Network check that Live reads a person's items and slots there (by their Network subject, with Live's own token and
+// X-OV-Subject), grants, equips, unequips and revokes there, never touches its own user_cosmetics / user_equipped, shows
+// nothing (rather than an error) when Inventory is down, and refuses writes with a clear message. Unset, Live keeps
+// its own tables.
+
+const assert = require('assert');
+const http = require('http');
+
+console.log = () => {};
+console.warn = () => {};
+
+const A = 'usr_01JZ0000000000000000000AAA';
+const DEFS = [
+ { id: 'itd_01JZ00000000000000000001R1', kind: 'live.name_effect', aliases: ['fx_rainbow'] },
+ { id: 'itd_01JZ00000000000000000001R2', kind: 'live.hat', aliases: ['hat_crown'] },
+ { id: 'itd_01JZ00000000000000000001R3', kind: 'live.particle', aliases: ['px_sparkle'] },
+];
+const state = { instances: [], slots: {}, calls: [], down: false };
+let seq = 0;
+
+function stub() {
+ return http.createServer((req, res) => {
+ const chunks = [];
+ req.on('data', (c) => chunks.push(c));
+ req.on('end', () => {
+ const body = chunks.length && /json/.test(String(req.headers['content-type'] || '')) ? JSON.parse(Buffer.concat(chunks).toString()) : null;
+ const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
+ const u = new URL(req.url, 'http://x');
+ if (u.pathname === '/oauth/token') return json(200, { access_token: 'tok_live', token_type: 'Bearer', expires_in: 300 });
+ state.calls.push({ method: req.method, path: u.pathname, subject: req.headers['x-ov-subject'] || null, auth: req.headers.authorization });
+ if (state.down) return json(503, { code: 'down' });
+ if (u.pathname === '/api/v1/definitions') return json(200, { definitions: DEFS });
+ if (u.pathname === '/api/v1/me/items') return json(200, { subject: req.headers['x-ov-subject'], instances: state.instances.filter((i) => i.owner === req.headers['x-ov-subject']), definitions: {}, next_cursor: null });
+ if (u.pathname === `/api/v1/people/${A}/equipped`) return json(200, { subject: A, slots: state.slots, updated_at: new Date().toISOString() });
+ if (u.pathname === '/api/v1/grants' && req.method === 'POST') {
+ const inst = { id: `inv_01JZ0000000000000000000${String(++seq).padStart(3, '0')}`.replace(/0(?=\d{3}$)/, '1'), definition_id: body.definition_id, owner: body.subject, origin: 'granted', state: 'owned', acquired_at: new Date().toISOString() };
+ state.instances.push(inst);
+ return json(201, { instance: inst, created: true });
+ }
+ if (u.pathname === '/api/v1/me/equipped' && req.method === 'PUT') {
+ const key = `${body.kind}:${body.slot}`;
+ if (body.instance_id === null) delete state.slots[key];
+ else { const inst = state.instances.find((i) => i.id === body.instance_id); state.slots[key] = { instance_id: inst.id, definition_id: inst.definition_id }; }
+ return json(200, { subject: A, slots: state.slots, updated_at: new Date().toISOString() });
+ }
+ const m = u.pathname.match(/^\/api\/v1\/instances\/([^/]+)\/revoke$/);
+ if (m) { const inst = state.instances.find((i) => i.id === m[1]); inst.state = 'revoked'; for (const [k, v] of Object.entries(state.slots)) if (v.instance_id === inst.id) delete state.slots[k]; return json(200, { instance: inst }); }
+ return json(404, { code: 'route.not_found' });
+ });
+ });
+}
+
+(async () => {
+ const server = stub();
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ const url = `http://127.0.0.1:${server.address().port}`;
+ process.env.INVENTORY_URL = url;
+ process.env.OV_NETWORK_INTERNAL_URL = url;
+ process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';
+ const db = require('../server/db/database');
+ await db.initDb();
+ const d = db.getDb();
+ await d.prepare("INSERT INTO users (id, username, display_name, password_hash) OVERRIDING SYSTEM VALUE VALUES (70, 'ana', 'Ana', '$sso$'), (71, 'nosub', 'No Sub', '$sso$')").run();
+ // Live learns a person's subject from their Network token (server/auth/identity-sync.js noteSubject).
+ await d.prepare(`INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (70, 'network', '9001', '${A}'), (71, 'network', '9002', NULL)`).run();
+ const cosmetics = require('../server/monetization/cosmetics');
+ const inventory = require('../server/monetization/inventory-client');
+ const ownRows = async () => Number((await d.prepare('SELECT count(*) AS n FROM user_cosmetics').get()).n) + Number((await d.prepare('SELECT count(*) AS n FROM user_equipped').get()).n);
+
+ try {
+ // Unset: Live's own tables.
+ delete process.env.INVENTORY_AUTHORITY;
+ assert.strictEqual(cosmetics.authority(), 'live');
+ assert.deepStrictEqual(await cosmetics.unlockCosmetic(70, 'fx_fire'), { success: true, item: cosmetics.COSMETICS.fx_fire });
+ assert.strictEqual(await ownRows(), 1, 'live keeps its own row');
+ await d.prepare('DELETE FROM user_cosmetics').run();
+ assert.strictEqual(state.calls.length, 0, 'nothing went to Inventory');
+
+ process.env.INVENTORY_AUTHORITY = 'inventory';
+ assert.strictEqual(cosmetics.authority(), 'inventory');
+
+ // Unlock and equip: in Inventory, for the person's subject, with Live's token.
+ assert.strictEqual((await cosmetics.unlockCosmetic(70, 'fx_rainbow')).success, true);
+ assert.strictEqual((await cosmetics.unlockCosmetic(70, 'fx_rainbow')).error, 'Already unlocked');
+ assert.strictEqual((await cosmetics.unlockCosmetic(70, 'hat_crown')).success, true);
+ const grant = state.calls.find((c) => c.path === '/api/v1/grants');
+ assert.strictEqual(grant.auth, 'Bearer tok_live');
+ assert.deepStrictEqual((await cosmetics.getUnlocked(70)).map((u) => u.item_id).sort(), ['fx_rainbow', 'hat_crown']);
+ assert.strictEqual((await cosmetics.equipCosmetic(70, 'fx_rainbow')).success, true);
+ assert.strictEqual((await cosmetics.equipCosmetic(70, 'px_sparkle')).error, 'You don\'t own this cosmetic');
+ assert.strictEqual(state.calls.filter((c) => c.path === '/api/v1/me/equipped').every((c) => c.subject === A), true, 'acting for her with X-OV-Subject');
+ const profile = await cosmetics.getCosmeticProfile(70);
+ assert.deepStrictEqual(profile.nameFX, { itemId: 'fx_rainbow', cssClass: 'name-fx-rainbow' });
+ assert.strictEqual(profile.hatFX, undefined);
+ const full = await cosmetics.getFullInventory(70);
+ assert.deepStrictEqual([full.categories.name_effect.map((c) => [c.itemId, c.equipped]), full.categories.hat.length], [[['fx_rainbow', true]], 1]);
+
+ // An admin equips something they do not own: granted, then equipped.
+ assert.strictEqual((await cosmetics.equipCosmetic(70, 'px_sparkle', { isAdmin: true })).success, true);
+ inventory.forget(A);
+ assert.strictEqual((await cosmetics.getEquipped(70)).particle, 'px_sparkle');
+
+ // Unequip and revoke.
+ assert.strictEqual((await cosmetics.unequipSlot(70, 'name_effect')).success, true);
+ inventory.forget(A);
+ assert.strictEqual((await cosmetics.getEquipped(70)).name_effect, undefined);
+ assert.strictEqual((await cosmetics.revokeCosmetic(70, 'hat_crown')).success, true);
+ assert.ok(!(await cosmetics.getUnlocked(70)).some((u) => u.item_id === 'hat_crown'));
+
+ // Live's own tables are not written in this mode.
+ assert.strictEqual(await ownRows(), 0, 'no row in user_cosmetics or user_equipped');
+
+ // A Live account without a Network subject holds nothing, and cannot be given anything.
+ assert.deepStrictEqual(await cosmetics.getUnlocked(71), []);
+ assert.match((await cosmetics.unlockCosmetic(71, 'fx_ice')).error, /subject/);
+
+ // Inventory down: reads show nothing (never a chat error); writes say so.
+ state.down = true;
+ inventory.forget(A);
+ assert.deepStrictEqual(await cosmetics.getCosmeticProfile(70), {});
+ assert.match((await cosmetics.equipCosmetic(70, 'px_sparkle')).error, /unavailable|own/i);
+ state.down = false;
+
+ // An unknown value falls back to live.
+ process.env.INVENTORY_AUTHORITY = 'nonsense';
+ assert.strictEqual(cosmetics.authority(), 'live');
+ process.stdout.write('cosmetics through OpenVibe.Inventory: all checks passed\n');
+ } finally {
+ server.close();
+ await db.close?.();
+ }
+ process.exit(0);
+})().catch((err) => { process.stderr.write(`${err.stack || err}\n`); process.exit(1); });
@@ -345,6 +345,7 @@ async function check(name, fn) {
@@ -345,6 +345,7 @@ async function check(name, fn) {
'server/pastes-client.js': 'OpenVibe.Community (configured)',
'server/pastes-client.js': 'OpenVibe.Community (configured)',
'server/comments-client.js': 'OpenVibe.Community (configured)',
'server/comments-client.js': 'OpenVibe.Community (configured)',
'server/chat/moderation-client.js': 'OpenVibe.Chat internal read API (configured)',
'server/chat/moderation-client.js': 'OpenVibe.Chat internal read API (configured)',
+ 'server/monetization/inventory-client.js': 'OpenVibe.Inventory API for Live cosmetics (configured INVENTORY_URL)',
'server/chat/insight-client.js': 'OpenVibe.Chat public chat-AI reads (configured OV_CHAT_INTERNAL_URL, fixed paths, ids validated)',
'server/chat/insight-client.js': 'OpenVibe.Chat public chat-AI reads (configured OV_CHAT_INTERNAL_URL, fixed paths, ids validated)',
'server/chat/chat-client.js': 'OpenVibe.Chat typed ingress (configured OV_CHAT_INTERNAL_URL, fixed /internal/chat/* paths)',
'server/chat/chat-client.js': 'OpenVibe.Chat typed ingress (configured OV_CHAT_INTERNAL_URL, fixed /internal/chat/* paths)',
'server/chat/chat-delivery.js': 'OpenVibe.Tools anon resolve (configured)',
'server/chat/chat-delivery.js': 'OpenVibe.Tools anon resolve (configured)',