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)

OP OpenVibers committed 9 Oct 2026 · 798f86e56f · 6 files, +371 −1

Changes

ddiff --git a/.env.example b/.env.example
iindex 5a4a41fa..3b6a3aaf 100644
---- a/.env.example
++++ b/.env.example
@@@ -196,4 +196,11 @@ OV_CHAT_INTERNAL_URL=http://127.0.0.1:4400
#BILLING_AUTHORITY=billing
OV_BILLING_INTERNAL_URL=http://127.0.0.1:4600
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
ddiff --git a/AGENTS.md b/AGENTS.md
iindex 55b9e4f6..2ac08212 100644
---- a/AGENTS.md
++++ b/AGENTS.md
@@@ -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
ddiff --git a/server/monetization/cosmetics.js b/server/monetization/cosmetics.js
iindex 25116cde..25c839c5 100644
---- a/server/monetization/cosmetics.js
++++ b/server/monetization/cosmetics.js
@@@ -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 equipped
const 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,
ddiff --git a/server/monetization/inventory-client.js b/server/monetization/inventory-client.js
nnew file mode 100644
iindex 00000000..f145162c
---- /dev/null
++++ b/server/monetization/inventory-client.js
@@@ -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 };
ddiff --git a/test/cosmetics-inventory.test.js b/test/cosmetics-inventory.test.js
nnew file mode 100644
iindex 00000000..666b6c7e
---- /dev/null
++++ b/test/cosmetics-inventory.test.js
@@@ -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); });
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 3a16b73e..5ec89121 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -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)',