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)
Changes
@@@ -197,10 +197,7 @@ OV_CHAT_INTERNAL_URL=http://127.0.0.1:4400OV_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++# 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@@@ -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@@@ -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 });});ddeleted file mode 100644@@@ -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 };@@@ -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,@@@ -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 100const 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 };@@@ -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();nnew file mode 100644@@@ -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');@@@ -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 rassert.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');
@@ -197,10 +197,7 @@ OV_CHAT_INTERNAL_URL=http://127.0.0.1:4400
@@ -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_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
+# 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
#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. **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
## Commands
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
* GET /channels/:id/policy → { channel, settings, moderator_ids, language }
* GET /channels/:id/policy → { channel, settings, moderator_ids, language }
* GET /channels/:id/approved-ip?ip → { approved }
* GET /channels/:id/approved-ip?ip → { approved }
* GET /bans?version → { version, bans } | { version, unchanged: true }
* 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 /settings → { settings } (tts_*, GIF and soundboard keys only)
* GET /anon/:num, /anon-first-seen?ip → { first_seen }
* GET /anon/:num, /anon-first-seen?ip → { first_seen }
* GET /tts-audio/:file → a clip from the arena voice cache
* GET /tts-audio/:file → a clip from the arena voice cache
@@ -213,18 +213,13 @@ contextRouter.get('/bans', async (req, res) => {
@@ -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) => {
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 = {};
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 });
res.json({ decor });
});
});
deleted file mode 100644
deleted file mode 100644
@@ -1,71 +0,0 @@
@@ -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 };
@@ -2,25 +2,15 @@
@@ -2,25 +2,15 @@
* 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.
+ * 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
* (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 inventory = require('./inventory-client');
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' };
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. */
/** 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; } };
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 = {
@@ -121,36 +111,24 @@ 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();
- 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 ────────────────────────
// ── 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 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 = {};
const result = {};
if (equipped.name_effect && COSMETICS[equipped.name_effect]) {
if (equipped.name_effect && COSMETICS[equipped.name_effect]) {
const c = COSMETICS[equipped.name_effect];
const c = COSMETICS[equipped.name_effect];
@@ -171,11 +149,30 @@ async function getCosmeticProfile(userId) {
@@ -171,11 +149,30 @@ async function getCosmeticProfile(userId) {
return result;
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 ────────────────────────────
// ── 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();
- 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) ────────────────────
// ── Unlock a cosmetic (add to collection) ────────────────────
@@ -183,39 +180,23 @@ async function unlockCosmetic(userId, itemId) {
@@ -183,39 +180,23 @@ 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();
- 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 ────────────────────────
// ── 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();
- // 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 ─────────────────────────────────────────
// ── Equip a cosmetic ─────────────────────────────────────────
@@ -231,33 +212,23 @@ async function equipCosmetic(userId, itemId, { isAdmin = false } = {}) {
@@ -231,33 +212,23 @@ 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();
- 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 ───────────────────────────────────────────
// ── 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();
- 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 ─────────────────────
// ── Get full inventory + equipped for UI ─────────────────────
@@ -283,10 +254,10 @@ async function getFullInventory(userId) {
@@ -283,10 +254,10 @@ async function getFullInventory(userId) {
module.exports = {
module.exports = {
COSMETICS,
COSMETICS,
- authority,
getUnlocked,
getUnlocked,
getEquipped,
getEquipped,
getCosmeticProfile,
getCosmeticProfile,
+ getCosmeticProfiles,
ownsCosmetic,
ownsCosmetic,
unlockCosmetic,
unlockCosmetic,
revokeCosmetic,
revokeCosmetic,
@@ -1,11 +1,12 @@
@@ -1,11 +1,12 @@
/**
/**
* OpenVibe.Live → OpenVibe.Inventory (ADR-054 §8): Live's cosmetics are items issued by `service:live` in the
* 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
* 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
* 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
* 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)
* 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
* 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`:
* 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';
@@ -20,6 +21,7 @@ const LIVE = 'service:live';
const TIMEOUT_MS = 5000;
const TIMEOUT_MS = 5000;
const CATALOG_TTL_MS = 10 * 60 * 1000;
const CATALOG_TTL_MS = 10 * 60 * 1000;
const EQUIPPED_TTL_MS = 30 * 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}$/;
const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
let tokens = null;
let tokens = null;
@@ -112,14 +114,10 @@ async function owned(subject) {
@@ -112,14 +114,10 @@ async function owned(subject) {
return out;
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 = {};
const value = {};
- for (const [key, v] of Object.entries(r.slots || {})) {
+ for (const [key, v] of Object.entries(slots || {})) {
const [kind, slot] = key.split(':');
const [kind, slot] = key.split(':');
if (!kind.startsWith('live.')) continue;
if (!kind.startsWith('live.')) continue;
const alias = byId.get(v.definition_id);
const alias = byId.get(v.definition_id);
@@ -129,6 +127,35 @@ async function equipped(subject) {
@@ -129,6 +127,35 @@ async function equipped(subject) {
if (equippedCache.size > 5000) equippedCache.delete(equippedCache.keys().next().value);
if (equippedCache.size > 5000) equippedCache.delete(equippedCache.keys().next().value);
return 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);
const forget = (subject) => equippedCache.delete(subject);
@@ -153,4 +180,4 @@ async function equip(subject, kind, slot, instanceId) {
@@ -153,4 +180,4 @@ async function equip(subject, kind, slot, instanceId) {
/** Tests only. */
/** Tests only. */
function reset() { tokens = null; catalogHit = null; equippedCache.clear(); subjects.clear(); lastLog = null; }
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 };
@@ -1,10 +1,9 @@
@@ -1,10 +1,9 @@
'use strict';
'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 assert = require('assert');
const http = require('http');
const http = require('http');
@@ -35,6 +34,12 @@ function stub() {
@@ -35,6 +34,12 @@ function stub() {
if (u.pathname === '/api/v1/definitions') return json(200, { definitions: DEFS });
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/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/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') {
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() };
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);
state.instances.push(inst);
@@ -68,20 +73,8 @@ function stub() {
@@ -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();
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 cosmetics = require('../server/monetization/cosmetics');
const inventory = require('../server/monetization/inventory-client');
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 {
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.
// 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')).success, true);
assert.strictEqual((await cosmetics.unlockCosmetic(70, 'fx_rainbow')).error, 'Already unlocked');
assert.strictEqual((await cosmetics.unlockCosmetic(70, 'fx_rainbow')).error, 'Already unlocked');
@@ -110,8 +103,23 @@ function stub() {
@@ -110,8 +103,23 @@ function stub() {
assert.strictEqual((await cosmetics.revokeCosmetic(70, 'hat_crown')).success, true);
assert.strictEqual((await cosmetics.revokeCosmetic(70, 'hat_crown')).success, true);
assert.ok(!(await cosmetics.getUnlocked(70)).some((u) => u.item_id === 'hat_crown'));
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.
// A Live account without a Network subject holds nothing, and cannot be given anything.
assert.deepStrictEqual(await cosmetics.getUnlocked(71), []);
assert.deepStrictEqual(await cosmetics.getUnlocked(71), []);
@@ -122,11 +130,9 @@ function stub() {
@@ -122,11 +130,9 @@ function stub() {
inventory.forget(A);
inventory.forget(A);
assert.deepStrictEqual(await cosmetics.getCosmeticProfile(70), {});
assert.deepStrictEqual(await cosmetics.getCosmeticProfile(70), {});
assert.match((await cosmetics.equipCosmetic(70, 'px_sparkle')).error, /unavailable|own/i);
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;
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');
process.stdout.write('cosmetics through OpenVibe.Inventory: all checks passed\n');
} finally {
} finally {
server.close();
server.close();
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -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');
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
* used the old game engine's getPlayer(), which INSERTed a placeholder game_players row for every
* 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
* 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
* 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
* node test/legacy-game-readonly.test.js
*/
*/
@@ -54,15 +54,8 @@ auth.requireAuth = (req, res) => res.status(401).json({ error: 'Authentication r
@@ -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(g.total_level, 20, 'a legacy player still shows their skills');
assert.strictEqual(await count(), 1, 'reading a profile created no game_players row');
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.
// The engine is gone and nothing reaches for it.
assert.ok(!fs.existsSync(path.join(ROOT, 'server/game')), 'server/game/ is retired');
assert.ok(!fs.existsSync(path.join(ROOT, 'server/game')), 'server/game/ is retired');