openvibers/openvibe.live Commits

Live: cosmetics live only in OpenVibe.Inventory (ADR-054 §8) — the INVENTORY_AUTHORITY switch and Live's table reads are gone, Chat's decor lookups read 100 people per Inventory call, and the legacy chat tags left with the game (#54)

OP OpenVibers committed 9 Oct 2026 · 85131ef443 · 9 files, +173 −223

Changes

ddiff --git a/.env.example b/.env.example
iindex 3b6a3aaf..ebeb5bac 100644
---- a/.env.example
++++ b/.env.example
@@@ -197,10 +197,7 @@ OV_CHAT_INTERNAL_URL=http://127.0.0.1:4400
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
++# Cosmetics live in OpenVibe.Inventory (ADR-054 §8): live.* items issued by service:live, Live's item ids as aliases.
++# Live reads and writes them with its service token (audience openvibe.inventory) acting for the person.
#INVENTORY_URL=http://127.0.0.1:5030
# OV_BILLING_TIMEOUT_MS=8000
ddiff --git a/AGENTS.md b/AGENTS.md
iindex 2ac08212..a07c6275 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. **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.
++**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:** OpenVibe.Inventory owns them since 2026-10-09 (ADR-054: `live.*` items issued by `service:live`, Live's item ids as aliases); [server/monetization/cosmetics.js](server/monetization/cosmetics.js) reads and writes through [inventory-client.js](server/monetization/inventory-client.js) (Chat's decor lookups batch 100 people per read) and Live keeps only the catalog's renderers. Live's `user_cosmetics`/`user_equipped`/`user_equipped_tag` are read by nothing (`test/cosmetics-tables-gone.test.js`) and are dropped by a contract migration.
## Commands
ddiff --git a/server/chat/live-context-routes.js b/server/chat/live-context-routes.js
iindex 29fdea13..55fb8893 100644
---- a/server/chat/live-context-routes.js
++++ b/server/chat/live-context-routes.js
@@@ -17,7 +17,7 @@
* GET /channels/:id/policy → { channel, settings, moderator_ids, language }
* GET /channels/:id/approved-ip?ip → { approved }
* GET /bans?version → { version, bans } | { version, unchanged: true }
-- * POST /decor {user_ids} → { decor: { id: { cosmetic, tag } } }
++ * POST /decor {user_ids} → { decor: { id: { cosmetic } } } (from OpenVibe.Inventory, 100 per read)
* GET /settings → { settings } (tts_*, GIF and soundboard keys only)
* GET /anon/:num, /anon-first-seen?ip → { first_seen }
* GET /tts-audio/:file → a clip from the arena voice cache
@@@ -213,18 +213,13 @@ contextRouter.get('/bans', async (req, res) => {
});
});
++// The legacy game's chat tags left with it: nothing has equipped one since, so a decor entry is the cosmetics alone
++// (OpenVibe.Chat reads a missing tag as none).
contextRouter.post('/decor', async (req, res) => {
-- const ids = (Array.isArray(req.body?.user_ids) ? req.body.user_ids : []).map(Number).filter(Number.isInteger).slice(0, 500);
-- let cosmetics = null, tags = null;
-- try { cosmetics = require('../monetization/cosmetics'); } catch { /* */ }
-- try { tags = require('./tags'); } catch { /* */ }
++ const ids = [...new Set((Array.isArray(req.body?.user_ids) ? req.body.user_ids : []).map(Number).filter(Number.isInteger))].slice(0, 500);
++ const profiles = await require('../monetization/cosmetics').getCosmeticProfiles(ids);
const decor = {};
-- for (const id of ids) {
-- let cosmetic = {}, tag = null;
-- try { if (cosmetics) cosmetic = await cosmetics.getCosmeticProfile(id) || {}; } catch { cosmetic = {}; }
-- try { if (tags) tag = await tags.getTagProfile(id) || null; } catch { tag = null; }
-- decor[id] = { cosmetic, tag };
-- }
++ for (const id of ids) decor[id] = { cosmetic: profiles[id] || {} };
res.json({ decor });
});
ddiff --git a/server/chat/tags.js b/server/chat/tags.js
ddeleted file mode 100644
iindex d9cb818a..00000000
---- a/server/chat/tags.js
++++ /dev/null
@@@ -1,71 +0,0 @@
--'use strict';
--/**
-- * Chat tags: the badge shown next to a username in chat (read-only).
-- *
-- * The tags came from the legacy HoboQuest game that ran inside Live (Tag Guardian fights, the Tag
-- * Master shop, achievement grants). OpenVibe.Games owns that game now, so Live no longer writes any
-- * of it: this module only reads what a user owns and has equipped, for chat lines, chat history
-- * and OpenVibe.Chat's decor lookups. The tables stay, so existing equipped tags keep showing.
-- */
--const db = require('../db/database');
--
--// ── Tag catalog ──────────────────────────────────────────────
--// category: 'special' (granted), 'shop' (was sold by the Tag Master NPC), 'achievement' (game milestone)
--const TAGS = {
-- // ── Special (granted, not purchasable) ───────────────────
-- cfo: { name: 'CFO', emoji: '💼', color: '#f59e0b', bgColor: '#451a03', desc: 'Chief Financial Officer — legacy RS-Companion VIP', category: 'special', tier: 5 },
-- founder: { name: 'Founder', emoji: '⭐', color: '#facc15', bgColor: '#422006', desc: 'Original OpenVibe.Live founder', category: 'special', tier: 5 },
-- legacy: { name: 'Legacy', emoji: '🏛️', color: '#a78bfa', bgColor: '#1e1b4b', desc: 'Migrated from RS-Companion', category: 'special', tier: 3 },
-- alpha_tester: { name: 'Alpha Tester', emoji: '🧪', color: '#34d399', bgColor: '#022c22', desc: 'Tested during alpha phase', category: 'special', tier: 3 },
-- mod: { name: 'Mod', emoji: '🛡️', color: '#60a5fa', bgColor: '#172554', desc: 'Platform moderator', category: 'special', tier: 4 },
-- admin: { name: 'Admin', emoji: '⚡', color: '#f87171', bgColor: '#450a0a', desc: 'Platform administrator', category: 'special', tier: 5 },
-- streamer: { name: 'Streamer', emoji: '📡', color: '#c084fc', bgColor: '#3b0764', desc: 'Verified streamer', category: 'special', tier: 4 },
-- vip: { name: 'VIP', emoji: '👑', color: '#fbbf24', bgColor: '#78350f', desc: 'Very Important Person', category: 'special', tier: 4 },
-- developer: { name: 'Dev', emoji: '🔧', color: '#38bdf8', bgColor: '#0c4a6e', desc: 'Platform developer', category: 'special', tier: 5 },
--
-- // ── Shop Tags (purchasable from Tag Master NPC) ──────────
-- noob: { name: 'Noob', emoji: '🐣', color: '#86efac', bgColor: '#052e16', desc: 'Everyone starts somewhere', category: 'shop', tier: 1, cost: 50 },
-- chatterbox: { name: 'Chatterbox', emoji: '💬', color: '#93c5fd', bgColor: '#1e3a5f', desc: 'Professional yapper', category: 'shop', tier: 1, cost: 100 },
-- warrior: { name: 'Warrior', emoji: '⚔️', color: '#fca5a5', bgColor: '#450a0a', desc: 'Battle-hardened fighter', category: 'shop', tier: 2, cost: 250 },
-- miner: { name: 'Miner', emoji: '⛏️', color: '#d4d4d8', bgColor: '#27272a', desc: 'Deep rock delver', category: 'shop', tier: 2, cost: 250 },
-- angler: { name: 'Angler', emoji: '🎣', color: '#7dd3fc', bgColor: '#0c4a6e', desc: 'Master of the rod', category: 'shop', tier: 2, cost: 250 },
-- lumberjack: { name: 'Lumberjack', emoji: '🪓', color: '#a3e635', bgColor: '#1a2e05', desc: 'Timber specialist', category: 'shop', tier: 2, cost: 250 },
-- farmer: { name: 'Farmer', emoji: '🌾', color: '#fde68a', bgColor: '#451a03', desc: 'Crop connoisseur', category: 'shop', tier: 2, cost: 250 },
-- chef: { name: 'Chef', emoji: '👨‍🍳', color: '#fdba74', bgColor: '#431407', desc: 'Culinary master', category: 'shop', tier: 2, cost: 300 },
-- merchant: { name: 'Merchant', emoji: '🏪', color: '#fcd34d', bgColor: '#78350f', desc: 'Shrewd trader', category: 'shop', tier: 3, cost: 500 },
-- explorer: { name: 'Explorer', emoji: '🧭', color: '#6ee7b7', bgColor: '#022c22', desc: 'Seen it all', category: 'shop', tier: 3, cost: 500 },
-- assassin: { name: 'Assassin', emoji: '🗡️', color: '#a78bfa', bgColor: '#1e1b4b', desc: 'Silent and deadly', category: 'shop', tier: 3, cost: 750 },
-- overlord: { name: 'Overlord', emoji: '🔱', color: '#e879f9', bgColor: '#4a044e', desc: 'Rules with an iron fist', category: 'shop', tier: 4, cost: 1500 },
-- legend: { name: 'Legend', emoji: '🏆', color: '#fbbf24', bgColor: '#451a03', desc: 'Etched in history', category: 'shop', tier: 5, cost: 5000 },
-- void_walker: { name: 'Void Walker', emoji: '🕳️', color: '#818cf8', bgColor: '#0f0f23', desc: 'From beyond the abyss', category: 'shop', tier: 5, cost: 10000 },
--
-- // ── Achievement Tags (earned, not purchasable) ───────────
-- dragon_slayer: { name: 'Dragonslayer', emoji: '🐉', color: '#ef4444', bgColor: '#450a0a', desc: 'Slew a dragon', category: 'achievement', tier: 4 },
-- dungeon_master: { name: 'Dungeon Master',emoji: '🏰', color: '#c084fc', bgColor: '#3b0764', desc: 'Won 50 dungeon fights', category: 'achievement', tier: 4 },
-- fish_lord: { name: 'Fish Lord', emoji: '🐟', color: '#22d3ee', bgColor: '#083344', desc: 'Caught every fish species', category: 'achievement', tier: 4 },
-- big_spender: { name: 'Big Spender', emoji: '💰', color: '#fbbf24', bgColor: '#78350f', desc: 'Spent 50,000 gold total', category: 'achievement', tier: 3 },
-- survivor: { name: 'Survivor', emoji: '💪', color: '#4ade80', bgColor: '#052e16', desc: 'Won 100 PvP battles', category: 'achievement', tier: 4 },
--};
--
--// `user_tags` went with roadmap T3: OpenVibe.Chat owns it (Live's copy was dropped in N+2) and no
--// production caller in Live ever read it here. The equipped tag (user_equipped_tag) is not staged and stays.
--
--/** The user's equipped tag, or null. */
--async function getEquippedTag(userId) {
-- const row = await db.getDb().prepare('SELECT tag_id FROM user_equipped_tag WHERE user_id = ?').get(userId);
-- if (!row) return null;
-- const tag = TAGS[row.tag_id];
-- return tag ? { tagId: row.tag_id, ...tag } : null;
--}
--
--/** What a chat line carries: { tagId, name, emoji, color, bgColor, … } or null. */
--async function getTagProfile(userId) {
-- return await getEquippedTag(userId);
--}
--
--/** The whole catalog. */
--function getAllTags() {
-- return Object.entries(TAGS).map(([id, t]) => ({ tagId: id, ...t }));
--}
--
--module.exports = { TAGS, getEquippedTag, getTagProfile, getAllTags };
ddiff --git a/server/monetization/cosmetics.js b/server/monetization/cosmetics.js
iindex 25c839c5..5dd8cc08 100644
---- a/server/monetization/cosmetics.js
++++ b/server/monetization/cosmetics.js
@@@ -2,25 +2,15 @@
* 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.
++ * Who owns them (ADR-054 §8): OpenVibe.Inventory, since 2026-10-09. They are live.* items issued by service:live with
++ * Live's item ids as aliases; this module reads and writes them through ./inventory-client.js, acting for the person
++ * with Live's own token. Live keeps only the catalog below (its renderers: CSS classes, particle glyphs, hats, speech
++ * presets). Live's old user_cosmetics / user_equipped / user_equipped_tag are read by nothing (test/cosmetics-tables-gone).
* (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; } };
@@@ -121,36 +111,24 @@ 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);
++ 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 }));
++ }, []);
}
// ── 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 = {};
-- for (const r of rows) equipped[r.slot] = r.item_id;
-- return equipped;
++ return await quietly(async () => {
++ const subject = await inventory.subjectOf(userId);
++ return subject ? await inventory.equipped(subject) : {};
++ }, {});
}
--// ── Get full cosmetic profile (for chat messages) ────────────
--async function getCosmeticProfile(userId) {
-- const equipped = await getEquipped(userId);
++/** What a chat line carries for one { slot → item id } set: { nameFX, particleFX, hatFX, voiceFX } (each optional). */
++function profileOf(equipped) {
const result = {};
if (equipped.name_effect && COSMETICS[equipped.name_effect]) {
const c = COSMETICS[equipped.name_effect];
@@@ -171,11 +149,30 @@ async function getCosmeticProfile(userId) {
return result;
}
++// ── Get full cosmetic profile (for chat messages) ────────────
++async function getCosmeticProfile(userId) {
++ return profileOf(await getEquipped(userId));
++}
++
++/**
++ * Many people's chat profiles at once (OpenVibe.Chat's decor lookups, up to 500 ids): { userId → profile }. One
++ * Inventory read per 100 people instead of one per person, so a full chat history stays inside Live's read budget.
++ */
++async function getCosmeticProfiles(userIds) {
++ const out = {};
++ for (const id of userIds) out[id] = {};
++ await quietly(async () => {
++ const subjects = new Map();
++ for (const id of userIds) { const s = await inventory.subjectOf(id); if (s) subjects.set(id, s); }
++ const sets = await inventory.equippedMany([...new Set(subjects.values())]);
++ for (const [id, s] of subjects) out[id] = profileOf(sets.get(s) || {});
++ }, null);
++ return out;
++}
++
// ── 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);
++ return (await getUnlocked(userId)).some((u) => u.item_id === itemId);
}
// ── Unlock a cosmetic (add to collection) ────────────────────
@@@ -183,39 +180,23 @@ 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 };
++ 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); }
}
// ── 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];
-- if (cosmetic) {
-- const slot = CATEGORY_SLOT[cosmetic.category];
-- await d.prepare('DELETE FROM user_equipped WHERE user_id = ? AND slot = ? AND item_id = ?').run(userId, slot, itemId);
-- }
-- await d.prepare('DELETE FROM user_cosmetics WHERE user_id = ? AND item_id = ?').run(userId, itemId);
-- return { success: true };
++ 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); }
}
// ── Equip a cosmetic ─────────────────────────────────────────
@@@ -231,33 +212,23 @@ 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 };
++ 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); }
}
// ── 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 };
++ 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); }
}
// ── Get full inventory + equipped for UI ─────────────────────
@@@ -283,10 +254,10 @@ async function getFullInventory(userId) {
module.exports = {
COSMETICS,
-- authority,
getUnlocked,
getEquipped,
getCosmeticProfile,
++ getCosmeticProfiles,
ownsCosmetic,
unlockCosmetic,
revokeCosmetic,
ddiff --git a/server/monetization/inventory-client.js b/server/monetization/inventory-client.js
iindex f145162c..f0bef582 100644
---- a/server/monetization/inventory-client.js
++++ b/server/monetization/inventory-client.js
@@@ -1,11 +1,12 @@
/**
* 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.
++ * network-wide inventory; cosmetics.js reads and writes them only 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)
++ * equippedMany(subjects) Map(subject → { slot → alias }), 100 people per read (OpenVibe.Chat's decor lookups)
* 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`:
@@@ -20,6 +21,7 @@ const LIVE = 'service:live';
const TIMEOUT_MS = 5000;
const CATALOG_TTL_MS = 10 * 60 * 1000;
const EQUIPPED_TTL_MS = 30 * 1000;
++const BATCH = 100; // Inventory's GET /equipped?subjects= takes at most 100
const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
let tokens = null;
@@@ -112,14 +114,10 @@ async function owned(subject) {
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`);
++/** One equipped set as Inventory answers it → { slot → alias } of its live.* items, kept for 30 s. */
++function remember(subject, slots, byId) {
const value = {};
-- for (const [key, v] of Object.entries(r.slots || {})) {
++ for (const [key, v] of Object.entries(slots || {})) {
const [kind, slot] = key.split(':');
if (!kind.startsWith('live.')) continue;
const alias = byId.get(v.definition_id);
@@@ -129,6 +127,35 @@ async function equipped(subject) {
if (equippedCache.size > 5000) equippedCache.delete(equippedCache.keys().next().value);
return value;
}
++const fresh = (subject) => { const hit = equippedCache.get(subject); return hit && Date.now() - hit.at < EQUIPPED_TTL_MS ? hit.value : null; };
++
++/** { slot → alias } of the live.* items the person wears (cached 30 s). */
++async function equipped(subject) {
++ const hit = fresh(subject);
++ if (hit) return hit;
++ const { byId } = await catalog();
++ const r = await call('GET', `/people/${encodeURIComponent(subject)}/equipped`);
++ return remember(subject, r.slots, byId);
++}
++
++/** Map(subject → { slot → alias }) for many people: the cached ones as they are, the rest 100 per read. */
++async function equippedMany(list) {
++ const out = new Map();
++ const missing = [];
++ for (const s of new Set(list)) {
++ if (!SUBJECT_RE.test(String(s))) continue;
++ const hit = fresh(s);
++ if (hit) out.set(s, hit); else missing.push(s);
++ }
++ if (!missing.length) return out;
++ const { byId } = await catalog();
++ for (let i = 0; i < missing.length; i += BATCH) {
++ const chunk = missing.slice(i, i + BATCH);
++ const r = await call('GET', `/equipped?subjects=${chunk.map(encodeURIComponent).join(',')}`);
++ for (const set of r.equipped || []) if (chunk.includes(set.subject)) out.set(set.subject, remember(set.subject, set.slots, byId));
++ }
++ return out;
++}
const forget = (subject) => equippedCache.delete(subject);
@@@ -153,4 +180,4 @@ async function equip(subject, kind, slot, instanceId) {
/** 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 };
++module.exports = { catalog, subjectOf, owned, equipped, equippedMany, grant, revoke, equip, forget, reset, InventoryUnavailable, AUDIENCE };
ddiff --git a/test/cosmetics-inventory.test.js b/test/cosmetics-inventory.test.js
iindex 666b6c7e..d3dc2c97 100644
---- a/test/cosmetics-inventory.test.js
++++ b/test/cosmetics-inventory.test.js
@@@ -1,10 +1,9 @@
'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.
++// ADR-054 §8: Live's cosmetics live in OpenVibe.Inventory (since 2026-10-09). 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, reads many people's sets 100 per call for OpenVibe.Chat's decor lookups,
++// shows nothing (rather than an error) when Inventory is down, and refuses writes with a clear message.
const assert = require('assert');
const http = require('http');
@@@ -35,6 +34,12 @@ function stub() {
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/equipped') {
++ const subjects = String(u.searchParams.get('subjects') || '').split(',').filter(Boolean);
++ state.calls[state.calls.length - 1].subjects = subjects.length;
++ if (subjects.length > 100) return json(400, { code: 'inventory.too_many_subjects' });
++ return json(200, { equipped: subjects.map((s) => ({ subject: s, slots: s === A ? state.slots : {}, updated_at: null })) });
++ }
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);
@@@ -68,20 +73,8 @@ function stub() {
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');
@@@ -110,8 +103,23 @@ function stub() {
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');
++ // OpenVibe.Chat's decor lookup: Ana and 250 others in three batched reads, never one per person; an account
++ // without a subject or an unknown id answers an empty profile.
++ await cosmetics.equipCosmetic(70, 'fx_rainbow');
++ const many = Array.from({ length: 250 }, (_, i) => 1000 + i);
++ await d.prepare("INSERT INTO users (id, username, display_name, password_hash) OVERRIDING SYSTEM VALUE SELECT g, 'p' || g, 'P' || g, '$sso$' FROM generate_series(1000, 1249) g").run();
++ await d.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) SELECT g, 'network', 'n' || g, 'usr_01JZ' || lpad(g::text, 22, '0') FROM generate_series(1000, 1249) g").run();
++ inventory.forget(A);
++ const before = state.calls.length;
++ const profiles = await cosmetics.getCosmeticProfiles([70, 71, 999999, ...many]);
++ const batched = state.calls.slice(before).filter((c) => c.path === '/api/v1/equipped');
++ assert.deepStrictEqual(batched.map((c) => c.subjects), [100, 100, 51], 'Ana and 250 others, 100 per read');
++ assert.strictEqual(state.calls.slice(before).filter((c) => /\/people\//.test(c.path)).length, 0, 'no per-person reads');
++ assert.deepStrictEqual(profiles[70].nameFX, { itemId: 'fx_rainbow', cssClass: 'name-fx-rainbow' });
++ assert.deepStrictEqual([profiles[71], profiles[999999], profiles[1000]], [{}, {}, {}]);
++ const again = state.calls.length;
++ await cosmetics.getCosmeticProfiles([70, ...many]);
++ assert.strictEqual(state.calls.length, again, 'a second lookup within 30 s is all cache');
// A Live account without a Network subject holds nothing, and cannot be given anything.
assert.deepStrictEqual(await cosmetics.getUnlocked(71), []);
@@@ -122,11 +130,9 @@ function stub() {
inventory.forget(A);
assert.deepStrictEqual(await cosmetics.getCosmeticProfile(70), {});
assert.match((await cosmetics.equipCosmetic(70, 'px_sparkle')).error, /unavailable|own/i);
++ inventory.reset();
++ assert.deepStrictEqual(await cosmetics.getCosmeticProfiles([70]), { 70: {} }, 'down: an empty profile, not an error');
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();
ddiff --git a/test/cosmetics-tables-gone.test.js b/test/cosmetics-tables-gone.test.js
nnew file mode 100644
iindex 00000000..ac19f79d
---- /dev/null
++++ b/test/cosmetics-tables-gone.test.js
@@@ -0,0 +1,32 @@
++'use strict';
++
++// ADR-054 §8: Live's cosmetics live in OpenVibe.Inventory since 2026-10-09 (imported and verified row for row first),
++// and the legacy game's chat tags left with the game. Nothing under server/ reads or writes Live's old
++// user_cosmetics, user_equipped or user_equipped_tag, so the contract migration that drops them breaks nothing.
++
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++
++const ROOT = path.join(__dirname, '..');
++const NAMES = /\b(user_cosmetics|user_equipped_tag|user_equipped)\b/;
++const isCommentLine = (line) => /^\s*(\/\/|\*|\/\*|--)/.test(line);
++
++function walk(dir, out = []) {
++ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
++ const p = path.join(dir, e.name);
++ if (e.isDirectory()) { if (e.name !== 'node_modules') walk(p, out); } else if (e.name.endsWith('.js')) out.push(p);
++ }
++ return out;
++}
++
++const offenders = [];
++for (const file of [...walk(path.join(ROOT, 'server')), ...walk(path.join(ROOT, 'scripts'))]) {
++ fs.readFileSync(file, 'utf8').split('\n').forEach((line, i) => {
++ if (!isCommentLine(line) && NAMES.test(line)) offenders.push(`${path.relative(ROOT, file)}:${i + 1}: ${line.trim().slice(0, 120)}`);
++ });
++}
++assert.deepStrictEqual(offenders, [], 'Live\'s cosmetics are OpenVibe.Inventory\'s (ADR-054 §8): read them through server/monetization/cosmetics.js');
++assert.ok(!fs.existsSync(path.join(ROOT, 'server/chat/tags.js')), 'the legacy chat tags left with the game');
++assert.ok(!/INVENTORY_AUTHORITY/.test(fs.readFileSync(path.join(ROOT, 'server/monetization/cosmetics.js'), 'utf8')), 'no switch back to Live\'s tables');
++console.log('cosmetics tables: nothing under server/ or scripts/ names them');
ddiff --git a/test/legacy-game-readonly.test.js b/test/legacy-game-readonly.test.js
iindex 0365162a..2eb887f9 100644
---- a/test/legacy-game-readonly.test.js
++++ b/test/legacy-game-readonly.test.js
@@@ -5,7 +5,7 @@
* used the old game engine's getPlayer(), which INSERTed a placeholder game_players row for every
* profile anyone opened (production had 70, the newest days old). The engine, its item tables and
* the RS-Companion import script are gone; the profile card reads db.getLegacyGameProfile(), which
-- * never writes, and chat tags come from the read-only server/chat/tags.js.
++ * never writes; the game's chat tags left with it (nothing equipped one since).
*
* node test/legacy-game-readonly.test.js
*/
@@@ -54,15 +54,8 @@ auth.requireAuth = (req, res) => res.status(401).json({ error: 'Authentication r
assert.strictEqual(g.total_level, 20, 'a legacy player still shows their skills');
assert.strictEqual(await count(), 1, 'reading a profile created no game_players row');
-- // Chat tags: read-only, and still shown.
-- const tags = require('../server/chat/tags');
-- // `user_tags` is OpenVibe.Chat's now (dropped in T3 N+2); only the equipped tag is Live's.
-- await raw.prepare("INSERT INTO user_equipped_tag (user_id, tag_id) VALUES (1, 'legacy')").run();
-- assert.strictEqual((await tags.getTagProfile(1)).name, 'Legacy');
-- assert.strictEqual(await tags.getTagProfile(2), null);
-- for (const writer of ['grantTag', 'revokeTag', 'buyTag', 'equipTag', 'unequipTag', 'fightGuardian']) {
-- assert.strictEqual(tags[writer], undefined, `tags.${writer} is gone (the game owns tag grants now)`);
-- }
++ // Chat tags left with the game: nothing equipped one since, and Live no longer reads them (ADR-054 §8).
++ assert.ok(!fs.existsSync(path.join(ROOT, 'server/chat/tags.js')), 'server/chat/tags.js is retired');
// The engine is gone and nothing reaches for it.
assert.ok(!fs.existsSync(path.join(ROOT, 'server/game')), 'server/game/ is retired');