openvibers/openvibe.live Commits
Internal routes take service tokens (plan T2, X-Internal-Key retirement, step 1 of the sweep): /internal/user-avatar (live.avatar.write), /internal/url-registry/refresh (live.url_registry.refresh), /internal/analytics-summary (live.analytics.read) and Chat's staged-table handoff /internal/chat-tables* (live.chat_mirror.write) each check the one capability they perform, loopback only. A Bearer is judged on the token alone; the key still passes without one while Network and Chat move (guardOrKey), then goes. The openvibe-quest game-item bridge is deleted (the quest game runs nowhere, nothing listens on :3200): questApi, activate/return-to-game, POST /api/cosmetics/internal-unlock, the 'Return to Game' buttons and their styles; /api/cosmetics/activate and /deactivate answer 410 for one release so old tabs keep their answers (ADR-016 N-1), then go. Tests: capability per route, audience, sandbox, a Bearer never downgraded to the key, the public edge refused; n-1 green. npm test 151/152, 1 skipped.
Changes
@@@ -57,8 +57,6 @@ OV_OAUTH_CLIENT_ID=liveOV_OAUTH_CLIENT_SECRET=# Per-service key for Network's /internal/* endpoints (header X-Internal-Key)INTERNAL_API_KEY=change-me-to-a-random-secret--# Legacy openvibe-quest game-item bridge (cosmetics ⇄ game inventory). Off unless set; authenticated with INTERNAL_API_KEY.--# LEGACY_QUEST_API_URL=http://127.0.0.1:3200# OpenVibe.Events (durable events): stream lifecycle is published through Live's outbox with the# OAuth client above (grant events.event.publish). Unset = off. EVENTS_PUBLISH=off also disables it.#EVENTS_URL=http://127.0.0.1:4300@@@ -418,15 +418,6 @@background: #ff4444;color: #fff;}--.cosmetic-btn-convert {-- background: #44aaff22;-- border-color: #44aaff;-- color: #44aaff;--}--.cosmetic-btn-convert:hover {-- background: #44aaff;-- color: #000;--}/* Empty state */.cosmetics-empty {@@@ -563,7 +563,7 @@ while True:<tbody><tr><td>GET</td><td>/api/cosmetics/catalog</td><td>No</td><td>Full catalog grouped by category</td></tr><tr><td>GET</td><td>/api/cosmetics/inventory · /equipped/:userId</td><td>Yes/No</td><td>My unlocked+equipped / a user's equipped (for chat render)</td></tr>--<tr><td>POST</td><td>/api/cosmetics/equip · /unequip · /activate · /deactivate</td><td>Yes</td><td>Equip a slot / consume a game item to unlock / revoke</td></tr>++<tr><td>POST</td><td>/api/cosmetics/equip · /unequip</td><td>Yes</td><td>Equip a slot / consume a game item to unlock / revoke</td></tr></tbody></table></div>@@@ -109,12 +109,10 @@ function renderCosmeticsUI() {if (isEquipped) {actionsHtml = `<button class="cosmetic-btn cosmetic-btn-unequip" onclick="cosmeticUnequip('${cat === 'name_effect' ? 'name_effect' : cat}')">Unequip</button>-- <button class="cosmetic-btn cosmetic-btn-convert" onclick="cosmeticDeactivate('${item.itemId}')" title="Send back to game inventory">Return to Game</button>`;} else {actionsHtml = `<button class="cosmetic-btn cosmetic-btn-equip" onclick="cosmeticEquip('${item.itemId}')">Equip</button>-- <button class="cosmetic-btn cosmetic-btn-convert" onclick="cosmeticDeactivate('${item.itemId}')" title="Send back to game inventory">Return to Game</button>`;}} else if (userIsAdmin) {@@@ -189,39 +187,6 @@ async function cosmeticUnequip(slot) {}}--async function cosmeticActivate(itemId) {-- try {-- const res = await fetch('/api/cosmetics/activate', {-- method: 'POST',-- headers: { 'Content-Type': 'application/json', ...authHeaders() },-- body: JSON.stringify({ itemId }),-- });-- const data = await res.json();-- if (data.error) { toast(data.error, 'error'); return; }-- toast(data.message || 'Unlocked!', 'success');-- loadCosmeticsInventory();-- } catch (err) {-- toast('Failed to activate', 'error');-- }--}----async function cosmeticDeactivate(itemId) {-- if (!confirm('Convert this cosmetic back to a game item? You will lose the global effect.')) return;-- try {-- const res = await fetch('/api/cosmetics/deactivate', {-- method: 'POST',-- headers: { 'Content-Type': 'application/json', ...authHeaders() },-- body: JSON.stringify({ itemId }),-- });-- const data = await res.json();-- if (data.error) { toast(data.error, 'error'); return; }-- toast(data.message || 'Converted back to game item', 'info');-- loadCosmeticsInventory();-- } catch (err) {-- toast('Failed to deactivate', 'error');-- }--}--/* ── Escape to close ──────────────────────────────────────────── */document.addEventListener('keydown', (e) => {if (e.key === 'Escape' && document.getElementById('cosmetics-modal')?.classList.contains('active')) {@@@ -606,9 +606,8 @@ app.post('/internal/openre-events', require('./openre/mirror').webhookHandler);// Internal (server-to-server) routes — allow openvibe.network to call into this service// Summary numbers for the Network's navigation service (ordering sites by real use).--// Internal key only; returns totals, never rows.--app.get('/internal/analytics-summary', (req, res) => {-- if (!require('./net/internal-key').internalKeyOk(req)) return res.status(401).json({ ok: false });++// A service token with live.analytics.read (loopback only); returns totals, never rows.++app.get('/internal/analytics-summary', require('./net/service-guard').guardOrKey('live.analytics.read'), (req, res) => {try {const days = Math.min(parseInt(req.query.days, 10) || 7, 90);const st = analytics.getStats({ days }) || {};@@@ -5,18 +5,12 @@ const router = express.Router();const config = require('../config');const db = require('../db/database');--const { internalKeyOk } = require('../net/internal-key');++const { guardOrKey } = require('../net/service-guard');--// Service-to-service only: loopback (nothing that came through nginx) and INTERNAL_API_KEY,--// compared in constant time (server/net/internal-key.js).--function requireInternalKey(req, res, next) {-- if (!internalKeyOk(req)) {-- return res.status(403).json({ error: 'Invalid or missing internal key' });-- }-- next();--}----router.use(requireInternalKey);++// Service-to-service only, loopback only (nothing that came through nginx): each route checks the one capability it++// performs on a Network service token (server/net/service-guard.js). The X-Internal-Key path is kept only while its++// callers move to tokens (plan T2).++const chatTablesGuard = guardOrKey('live.chat_mirror.write'); // OpenVibe.Chat's staged-table handoff (T3 deletes)// CHAT_AUTHORITY=chat: OpenVibe.Chat caches users; a role or avatar pushed here reaches it at once.function notifyChat(userId) {@@@ -29,18 +23,18 @@ function notifyChat(userId) {// POST /internal/chat-tables/:table { dual_read?: bool, reset_counters?: bool, authority?: 'live'|'chat',// by?: 'who', force?: true (back to 'live' with Chat down) } — the// flag, the counters, then the handoff--router.get('/chat-tables', async (req, res) => {++router.get('/chat-tables', chatTablesGuard, async (req, res) => {try { res.json(await require('../chat/chat-tables').status()); } catch (err) { res.status(500).json({ error: err.message }); }});// POST /internal/chat-tables/relay { paused: bool } — hold the relay to Chat (the capture goes on) for an import--router.post('/chat-tables/relay', (req, res) => {++router.post('/chat-tables/relay', chatTablesGuard, (req, res) => {try {const sync = require('../chat/chat-tables-sync');if (req.body && req.body.paused !== undefined) sync.setPaused(req.body.paused === true || req.body.paused === 'true');res.json({ ok: true, relay: sync.relayStats() });} catch (err) { res.status(500).json({ error: err.message }); }});--router.post('/chat-tables/:table', async (req, res) => {++router.post('/chat-tables/:table', chatTablesGuard, async (req, res) => {const chatTables = require('../chat/chat-tables');const table = String(req.params.table);const b = req.body || {};@@@ -61,7 +55,7 @@ router.post('/chat-tables/:table', async (req, res) => {// Footer site copy is written by OpenVibe.AI for the Network directly (network.site_copy); the// /internal/ai/site-copy fallback that used to live here was retired on 2026-09-23.--router.post('/url-registry/refresh', async (req, res) => {++router.post('/url-registry/refresh', guardOrKey('live.url_registry.refresh'), async (req, res) => {try {await config.refreshRegistry();console.log('[Internal] URL registry refresh requested');@@@ -79,7 +73,7 @@ router.post('/url-registry/refresh', async (req, res) => {// ── The account's avatar changed on the Network (or on another site) ─────────// The avatar is a network-wide property (OpenVibe.Network server/profile/avatar.js). Live keeps a copy on its// own user row because every stream card, chat line and profile reads it locally.--router.post('/user-avatar', (req, res) => {++router.post('/user-avatar', guardOrKey('live.avatar.write'), (req, res) => {try {const { username, openvibenetwork_id, avatar_url } = req.body || {};let url = null;@@@ -6,8 +6,6 @@* GET /api/cosmetics/equipped/:userId - Public: get equipped cosmetics for a user* POST /api/cosmetics/equip - Equip a cosmetic* POST /api/cosmetics/unequip - Unequip a slot-- * POST /api/cosmetics/activate - Consume game item → unlock cosmetic-- * POST /api/cosmetics/deactivate - Revoke cosmetic → return game item*/const express = require('express');const { requireAuth } = require('../auth/auth');@@@ -60,43 +58,11 @@ router.post('/unequip', requireAuth, (req, res) => {res.json(result);});--// ── Activate: consume game item → unlock cosmetic globally ───--router.post('/activate', requireAuth, async (req, res) => {-- const { itemId } = req.body;-- if (!itemId) return res.status(400).json({ error: 'itemId required' });-- const result = await cosmetics.activateFromGame(req.user.id, itemId);-- if (result.error) return res.status(400).json(result);-- res.json(result);--});----// ── Deactivate: revoke cosmetic → add back to game inventory ─--router.post('/deactivate', requireAuth, async (req, res) => {-- const { itemId } = req.body;-- if (!itemId) return res.status(400).json({ error: 'itemId required' });-- const result = await cosmetics.deactivateToGame(req.user.id, itemId);-- if (result.error) return res.status(400).json(result);-- res.json(result);--});----// ── Internal: auto-unlock cosmetic from openvibe-quest game ──────--// Called server-to-server when openvibe-quest game awards a hat/cosmetic item.--// Only the configured internal API key, from loopback: the legacy X-Internal-Secret is in git--// history, and the quest game that sent it from this host is gone. The key is shared by several--// services, so it is refused through the public edge (nginx/Cloudflare) like every internal route--// (server/net/internal-key.js); otherwise anyone holding it could unlock paid cosmetics from outside.--router.post('/internal-unlock', (req, res) => {-- if (!require('../net/internal-key').internalKeyOk(req)) {-- return res.status(403).json({ error: 'Forbidden' });-- }-- const { userId, itemId } = req.body;-- if (!userId || !itemId) return res.status(400).json({ error: 'userId and itemId required' });-- try {-- const result = cosmetics.unlockCosmetic(userId, itemId);-- res.json(result);-- } catch (err) {-- console.error(`[Cosmetics] internal-unlock error for user ${userId}, item ${itemId}:`, err.message);-- res.status(400).json({ error: 'Failed to unlock — user may not exist on openvibelive' });-- }--});++// The openvibe-quest game-item bridge is gone (plan T2; the quest game runs nowhere, so these always failed). Open++// tabs of the previous release may still call them for one release (ADR-016 N-1): they answer 410 behind the same++// auth, then the next release deletes both.++for (const p of ['/activate', '/deactivate']) {++ router.post(p, requireAuth, (req, res) => res.status(410).json({ error: 'Game items no longer convert to cosmetics' }));++}module.exports = router;@@@ -1,16 +1,10 @@/*** OpenVibe.Live — Cosmetics System-- * Global cosmetic items (name effects, particles, hats, voices)-- * Unlocked via OpenVibeGame items, equipped globally for chat/overlay.-- * Can be converted back to game items for trading.++ * Global cosmetic items (name effects, particles, hats, voices), equipped globally for chat/overlay.++ * (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 http = require('http');----// openvibe-quest internal API base (server-to-server). The quest game no longer runs on this host--// (2026-09-23), so the game-item bridge is off unless LEGACY_QUEST_API_URL names one; it--// authenticates with INTERNAL_API_KEY, never a secret written into the code.--const LEGACY_QUEST_API = String(process.env.LEGACY_QUEST_API_URL || '').replace(/\/+$/, '');// ── Cosmetic Catalog (defines all cosmetics and their CSS/rendering data) ───const COSMETICS = {@@@ -220,77 +214,6 @@ function unequipSlot(userId, slot) {return { success: true, slot };}--// ── Helper: call openvibe-quest internal API ─────────────────────--function questApi(method, path, body) {-- if (!LEGACY_QUEST_API) return Promise.reject(new Error('the quest game bridge is off (LEGACY_QUEST_API_URL is not set)'));-- return new Promise((resolve, reject) => {-- const data = body ? JSON.stringify(body) : null;-- const req = http.request(`${LEGACY_QUEST_API}${path}`, {-- method,-- headers: {-- 'Content-Type': 'application/json',-- 'X-Internal-Key': String(require('../config').internalApiKey || ''),-- ...(data ? { 'Content-Length': Buffer.byteLength(data) } : {}),-- },-- timeout: 5000,-- }, (res) => {-- let chunks = '';-- res.on('data', (c) => chunks += c);-- res.on('end', () => {-- try { resolve(JSON.parse(chunks)); } catch { resolve({}); }-- });-- });-- req.on('error', reject);-- req.on('timeout', () => { req.destroy(); reject(new Error('openvibe-quest timeout')); });-- if (data) req.write(data);-- req.end();-- });--}----// ── Activate: consume game item → unlock global cosmetic ─────--async function activateFromGame(userId, itemId) {-- const cosmetic = COSMETICS[itemId];-- if (!cosmetic) return { error: 'This item has no global cosmetic' };-- if (ownsCosmetic(userId, itemId)) return { error: 'Already unlocked globally' };---- // Ask openvibe-quest to check & consume game inventory item-- try {-- const result = await questApi('POST', '/api/internal/inventory/consume', { userId, itemId, quantity: 1 });-- if (result.error) return { error: result.error };-- } catch {-- return { error: 'Game server unavailable — try again later' };-- }---- // Unlock the cosmetic in openvibelive's DB-- const d = db.getDb();-- d.prepare('INSERT OR IGNORE INTO user_cosmetics (user_id, item_id, category) VALUES (?, ?, ?)').run(userId, itemId, cosmetic.category);-- return { success: true, message: `Unlocked ${cosmetic.name} globally!`, item: cosmetic };--}----// ── Deactivate: revoke global cosmetic → add back to game inventory ──--async function deactivateToGame(userId, itemId) {-- const cosmetic = COSMETICS[itemId];-- if (!cosmetic) return { error: 'Unknown cosmetic' };-- if (!ownsCosmetic(userId, itemId)) return { error: 'You don\'t own this cosmetic' };---- // Add item back to openvibe-quest game inventory-- try {-- const result = await questApi('POST', '/api/internal/inventory/add', { userId, itemId, quantity: 1 });-- if (result.error) return { error: result.error };-- } catch {-- return { error: 'Game server unavailable — try again later' };-- }---- const d = db.getDb();-- // Unequip if equipped-- const slot = CATEGORY_SLOT[cosmetic.category];-- d.prepare('DELETE FROM user_equipped WHERE user_id = ? AND slot = ? AND item_id = ?').run(userId, slot, itemId);-- // Remove from cosmetics-- d.prepare('DELETE FROM user_cosmetics WHERE user_id = ? AND item_id = ?').run(userId, itemId);---- return { success: true, message: `Converted ${cosmetic.name} back to game item` };--}--// ── Get full inventory + equipped for UI ─────────────────────function getFullInventory(userId) {const unlocked = getUnlocked(userId);@@@ -323,7 +246,5 @@ module.exports = {revokeCosmetic,equipCosmetic,unequipSlot,-- activateFromGame,-- deactivateToGame,getFullInventory,};@@@ -7,10 +7,8 @@* loopback callers never send them. Internal routes refuse such a request.* internalKeyMatches(k) timing-safe comparison with INTERNAL_API_KEY (both sides hashed first, so* neither the content nor the length leaks through timing).-- * internalKeyOk(req) both: loopback and the right X-Internal-Key. Every X-Internal-Key route uses-- * this (server/internal/routes.js, /internal/analytics-summary,-- * POST /api/cosmetics/internal-unlock); service-token routes use-- * server/net/service-guard.js, which applies the same viaProxy rule.++ * internalKeyOk(req) both: loopback and the right X-Internal-Key. Only service-guard.js guardOrKey++ * uses it, for callers still moving to service tokens (plan T2 deletes it).*/const crypto = require('crypto');@@@ -38,4 +38,18 @@ function guard(capability) {};}--module.exports = { guard, AUDIENCE };++/**++ * While X-Internal-Key is retired (plan T2): a request that presents a Bearer is judged on the token alone (never++ * downgraded to the key); one without a Bearer still passes with the key from loopback. Callers move to tokens, then++ * every route becomes guard(capability) and the key goes.++ */++function guardOrKey(capability) {++ const tokenGuard = guard(capability);++ return function liveServiceGuardOrKey(req, res, next) {++ if (String(req.headers.authorization || '').startsWith('Bearer ')) return tokenGuard(req, res, next);++ if (require('./internal-key').internalKeyOk(req)) return next();++ return http.sendProblem(res, 401, 'token.missing', { detail: 'a service token is required', ctx: http.requestContext(req.headers) });++ };++}++++module.exports = { guard, guardOrKey, AUDIENCE };@@@ -286,8 +286,8 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));assert.strictEqual((await internal('POST', '/chat-tables/channel_moderation_settings', { reset_counters: true })).body.table.dual_read_stats.compared, 0);// 6. The status route: loopback + internal key; both sides' view of every table.-- assert.strictEqual((await call('GET', '/internal/chat-tables')).status, 403);-- assert.strictEqual((await call('GET', '/internal/chat-tables', { headers: { ...KEY, 'X-Forwarded-For': '203.0.113.1' } })).status, 403);++ assert.strictEqual((await call('GET', '/internal/chat-tables')).status, 401, 'no credentials');++ assert.strictEqual((await call('GET', '/internal/chat-tables', { headers: { ...KEY, 'X-Forwarded-For': '203.0.113.1' } })).status, 401, 'the key through the public edge');let s = (await internal('GET', '/chat-tables')).body;assert.strictEqual(s.chat_authority, 'chat');assert.deepStrictEqual([s.tables.emotes.authority, s.tables.emotes.chat_authority, s.tables.emotes.dual_read, s.tables.emotes.outbox_pending], ['live', 'live', true, 0]);@@@ -1,27 +1,38 @@'use strict';--// POST /api/cosmetics/internal-unlock grants paid cosmetics, so only the configured INTERNAL_API_KEY--// opens it, and only from loopback. The legacy X-Internal-Secret is in the repository's history:--// from loopback, without proxy headers, it used to be enough on its own. The key is shared by--// Network, Live, Media and Tools, so a request that came through the public edge (nginx adds--// X-Forwarded-For / X-Real-IP, Cloudflare CF-Connecting-IP) is refused even with the right key.--// The same rule and a constant-time compare cover the other X-Internal-Key routes--// (server/net/internal-key.js): /internal/* and /internal/analytics-summary.++// Live's internal (server-to-server) routes, during the X-Internal-Key retirement (plan T2):++// - each route checks the one capability it performs on a Network service token (server/net/service-guard.js);++// - a request that presents a Bearer is judged on the token alone, never downgraded to the key;++// - without a Bearer, the key still passes from loopback while callers move (guardOrKey), compared in constant time;++// - nothing that came through nginx or Cloudflare gets in, with a token or the key;++// - POST /api/cosmetics/internal-unlock (the openvibe-quest bridge) is gone: the quest game runs nowhere.const assert = require('assert');++const crypto = require('crypto');const fs = require('fs');const os = require('os');const path = require('path');const express = require('express');++const { serviceAuth } = require('openvibe-contracts');--const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-cosmetics-internal-'));++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-live-internal-'));++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });++fs.writeFileSync(path.join(tmp, 'network.pem'), keys.publicKey);process.env.DB_PATH = path.join(tmp, 'live.db');++process.env.OV_NETWORK_PUBLIC_KEY = path.join(tmp, 'network.pem');++process.env.OV_NETWORK_URL = 'https://openvibe.network';const KEY = 'k'.repeat(40);process.env.INTERNAL_API_KEY = KEY;console.log = () => {};console.warn = () => {};console.error = () => {};++const now = () => Math.floor(Date.now() / 1000);++let jti = 0;++const token = (cap, { aud = 'openvibe.live', sub = 'svc:network', env } = {}) => serviceAuth.signServiceToken({++ iss: 'https://openvibe.network', sub, actor_type: 'service', aud: [aud], cap, iat: now(), exp: now() + 300, jti: `tok_test_${String(++jti).padStart(4, '0')}`, ...(env ? { env } : {}),++}, keys.privateKey);++(async () => {require('../server/db/database').initDb();const app = express();@@@ -32,32 +43,46 @@ console.error = () => {};await new Promise((r) => server.once('listening', r));const origin = `http://127.0.0.1:${server.address().port}`;const post = (p, headers, body = {}) => fetch(origin + p, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body: JSON.stringify(body) });-- const unlock = (headers) => post('/api/cosmetics/internal-unlock', headers);++ const bearer = (t) => ({ authorization: `Bearer ${t}` });try {-- // ── internal-unlock ──────────────────────────────────────────────────────────-- assert.strictEqual((await unlock({ 'x-internal-secret': 'openvibe-internal-2026' })).status, 403, 'the legacy secret from loopback no longer opens it');-- assert.strictEqual((await unlock({})).status, 403);-- assert.strictEqual((await unlock({ 'x-internal-key': 'wrong' })).status, 403);-- assert.strictEqual((await unlock({ 'x-internal-key': 'j'.repeat(40) })).status, 403, 'a wrong key of the right length');-- assert.strictEqual((await unlock({ 'x-internal-key': KEY + 'x' })).status, 403, 'a longer key that starts right');-- assert.strictEqual((await unlock({ 'x-internal-key': KEY })).status, 400, 'the internal key from loopback gets past the gate (then needs userId/itemId)');-- for (const h of ['x-forwarded-for', 'x-real-ip', 'cf-connecting-ip']) {-- assert.strictEqual((await unlock({ 'x-internal-key': KEY, [h]: '203.0.113.9' })).status, 403, `the right key through the public edge (${h}) is refused`);++ // ── the quest bridge is gone ─────────────────────────────────────────────────++ for (const h of [{ 'x-internal-key': KEY }, { 'x-internal-secret': 'openvibe-internal-2026' }]) {++ assert.strictEqual((await post('/api/cosmetics/internal-unlock', h, { userId: 1, itemId: 'fx_fire' })).status, 404, 'internal-unlock no longer exists');}++ // activate/deactivate answer 410 for one release (old tabs, ADR-016), behind the same auth.++ for (const p of ['/api/cosmetics/activate', '/api/cosmetics/deactivate']) assert.strictEqual((await post(p, {}, { itemId: 'fx_fire' })).status, 401, `${p} still needs a session`);-- // ── the other X-Internal-Key routes: same rule ──────────────────────────────++ // ── /internal/user-avatar: live.avatar.write ─────────────────────────────────const avatar = (headers) => post('/internal/user-avatar', headers, { username: 'nobody-here' });-- assert.strictEqual((await avatar({ 'x-internal-key': KEY })).status, 404, 'the right key from loopback reaches /internal/*');-- assert.strictEqual((await avatar({ 'x-internal-key': 'j'.repeat(40) })).status, 403);-- assert.strictEqual((await avatar({ 'x-internal-key': KEY, 'x-forwarded-for': '203.0.113.9' })).status, 403);-- assert.strictEqual((await avatar({ 'x-internal-key': KEY, 'cf-connecting-ip': '203.0.113.9' })).status, 403);++ assert.strictEqual((await avatar(bearer(token(['live.avatar.write'])))).status, 404, 'the capability reaches the handler (no such user)');++ assert.strictEqual((await avatar(bearer(token(['live.url_registry.refresh'])))).status, 403, 'another capability is not enough');++ assert.strictEqual((await avatar(bearer(token(['live.avatar.write'], { aud: 'openvibe.network' })))).status, 401, 'a token for another audience');++ assert.strictEqual((await avatar(bearer(token(['live.avatar.write'], { env: 'sandbox' })))).status, 401, 'a sandbox token');++ assert.strictEqual((await avatar({ ...bearer('not.a.token'), 'x-internal-key': KEY })).status, 401, 'a Bearer is judged on the token alone, never downgraded to the key');++ assert.strictEqual((await avatar({ ...bearer(token(['live.avatar.write'])), 'x-forwarded-for': '203.0.113.9' })).status, 403, 'a token through the public edge is refused');++ assert.strictEqual((await avatar({})).status, 401);++ // The key while callers move: loopback only, constant time.++ assert.strictEqual((await avatar({ 'x-internal-key': KEY })).status, 404, 'the key from loopback still reaches the handler');++ assert.strictEqual((await avatar({ 'x-internal-key': 'j'.repeat(40) })).status, 401, 'a wrong key of the right length');++ assert.strictEqual((await avatar({ 'x-internal-key': KEY + 'x' })).status, 401, 'a longer key that starts right');++ for (const h of ['x-forwarded-for', 'x-real-ip', 'cf-connecting-ip']) {++ assert.strictEqual((await avatar({ 'x-internal-key': KEY, [h]: '203.0.113.9' })).status, 401, `the right key through the public edge (${h}) is refused`);++ }++++ // ── the other internal routes: each has its own capability ───────────────────++ assert.strictEqual((await post('/internal/url-registry/refresh', bearer(token(['live.avatar.write'])))).status, 403);++ assert.notStrictEqual((await post('/internal/url-registry/refresh', bearer(token(['live.url_registry.refresh'])))).status, 403, 'live.url_registry.refresh opens the refresh');++ const tables = (headers) => fetch(`${origin}/internal/chat-tables`, { headers });++ assert.strictEqual((await tables(bearer(token(['live.avatar.write'], { sub: 'svc:chat' })))).status, 403);++ assert.notStrictEqual((await tables(bearer(token(['live.chat_mirror.write'], { sub: 'svc:chat' })))).status, 403, 'Chat opens the staged-table handoff with live.chat_mirror.write');++ assert.strictEqual((await tables({})).status, 401);const ik = require('../server/net/internal-key');assert.strictEqual(ik.internalKeyMatches(KEY), true);assert.strictEqual(ik.internalKeyMatches(''), false);assert.strictEqual(ik.internalKeyMatches(KEY.slice(0, 39)), false);-- // No internal-key compare with === / !== is left anywhere in the server.++ // No internal-key compare with === / !== anywhere in the server.const offenders = [];const walk = (dir) => {for (const e of fs.readdirSync(dir, { withFileTypes: true })) {@@@ -71,7 +96,7 @@ console.error = () => {};};walk(path.join(__dirname, '..', 'server'));assert.deepStrictEqual(offenders, [], 'internal keys are compared with internalKeyMatches (constant time)');-- process.stdout.write('cosmetics internal-unlock: all checks passed\n');++ process.stdout.write('live internal routes: all checks passed\n');} finally {server.close();fs.rmSync(tmp, { recursive: true, force: true });
@@ -57,8 +57,6 @@ OV_OAUTH_CLIENT_ID=live
@@ -57,8 +57,6 @@ OV_OAUTH_CLIENT_ID=live
OV_OAUTH_CLIENT_SECRET=
OV_OAUTH_CLIENT_SECRET=
# Per-service key for Network's /internal/* endpoints (header X-Internal-Key)
# Per-service key for Network's /internal/* endpoints (header X-Internal-Key)
INTERNAL_API_KEY=change-me-to-a-random-secret
INTERNAL_API_KEY=change-me-to-a-random-secret
-# Legacy openvibe-quest game-item bridge (cosmetics ⇄ game inventory). Off unless set; authenticated with INTERNAL_API_KEY.
-# LEGACY_QUEST_API_URL=http://127.0.0.1:3200
# OpenVibe.Events (durable events): stream lifecycle is published through Live's outbox with the
# OpenVibe.Events (durable events): stream lifecycle is published through Live's outbox with the
# OAuth client above (grant events.event.publish). Unset = off. EVENTS_PUBLISH=off also disables it.
# OAuth client above (grant events.event.publish). Unset = off. EVENTS_PUBLISH=off also disables it.
#EVENTS_URL=http://127.0.0.1:4300
#EVENTS_URL=http://127.0.0.1:4300
@@ -418,15 +418,6 @@
@@ -418,15 +418,6 @@
background: #ff4444;
background: #ff4444;
color: #fff;
color: #fff;
}
}
-.cosmetic-btn-convert {
- background: #44aaff22;
- border-color: #44aaff;
- color: #44aaff;
-}
-.cosmetic-btn-convert:hover {
- background: #44aaff;
- color: #000;
-}
/* Empty state */
/* Empty state */
.cosmetics-empty {
.cosmetics-empty {
@@ -563,7 +563,7 @@ while True:
@@ -563,7 +563,7 @@ while True:
<tbody>
<tbody>
<tr><td>GET</td><td>/api/cosmetics/catalog</td><td>No</td><td>Full catalog grouped by category</td></tr>
<tr><td>GET</td><td>/api/cosmetics/catalog</td><td>No</td><td>Full catalog grouped by category</td></tr>
<tr><td>GET</td><td>/api/cosmetics/inventory · /equipped/:userId</td><td>Yes/No</td><td>My unlocked+equipped / a user's equipped (for chat render)</td></tr>
<tr><td>GET</td><td>/api/cosmetics/inventory · /equipped/:userId</td><td>Yes/No</td><td>My unlocked+equipped / a user's equipped (for chat render)</td></tr>
-<tr><td>POST</td><td>/api/cosmetics/equip · /unequip · /activate · /deactivate</td><td>Yes</td><td>Equip a slot / consume a game item to unlock / revoke</td></tr>
+<tr><td>POST</td><td>/api/cosmetics/equip · /unequip</td><td>Yes</td><td>Equip a slot / consume a game item to unlock / revoke</td></tr>
</tbody>
</tbody>
</table>
</table>
</div>
</div>
@@ -109,12 +109,10 @@ function renderCosmeticsUI() {
@@ -109,12 +109,10 @@ function renderCosmeticsUI() {
if (isEquipped) {
if (isEquipped) {
actionsHtml = `
actionsHtml = `
<button class="cosmetic-btn cosmetic-btn-unequip" onclick="cosmeticUnequip('${cat === 'name_effect' ? 'name_effect' : cat}')">Unequip</button>
<button class="cosmetic-btn cosmetic-btn-unequip" onclick="cosmeticUnequip('${cat === 'name_effect' ? 'name_effect' : cat}')">Unequip</button>
- <button class="cosmetic-btn cosmetic-btn-convert" onclick="cosmeticDeactivate('${item.itemId}')" title="Send back to game inventory">Return to Game</button>
`;
`;
} else {
} else {
actionsHtml = `
actionsHtml = `
<button class="cosmetic-btn cosmetic-btn-equip" onclick="cosmeticEquip('${item.itemId}')">Equip</button>
<button class="cosmetic-btn cosmetic-btn-equip" onclick="cosmeticEquip('${item.itemId}')">Equip</button>
- <button class="cosmetic-btn cosmetic-btn-convert" onclick="cosmeticDeactivate('${item.itemId}')" title="Send back to game inventory">Return to Game</button>
`;
`;
}
}
} else if (userIsAdmin) {
} else if (userIsAdmin) {
@@ -189,39 +187,6 @@ async function cosmeticUnequip(slot) {
@@ -189,39 +187,6 @@ async function cosmeticUnequip(slot) {
}
}
}
}
-async function cosmeticActivate(itemId) {
- try {
- const res = await fetch('/api/cosmetics/activate', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json', ...authHeaders() },
- body: JSON.stringify({ itemId }),
- });
- const data = await res.json();
- if (data.error) { toast(data.error, 'error'); return; }
- toast(data.message || 'Unlocked!', 'success');
- loadCosmeticsInventory();
- } catch (err) {
- toast('Failed to activate', 'error');
- }
-}
-
-async function cosmeticDeactivate(itemId) {
- if (!confirm('Convert this cosmetic back to a game item? You will lose the global effect.')) return;
- try {
- const res = await fetch('/api/cosmetics/deactivate', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json', ...authHeaders() },
- body: JSON.stringify({ itemId }),
- });
- const data = await res.json();
- if (data.error) { toast(data.error, 'error'); return; }
- toast(data.message || 'Converted back to game item', 'info');
- loadCosmeticsInventory();
- } catch (err) {
- toast('Failed to deactivate', 'error');
- }
-}
-
/* ── Escape to close ──────────────────────────────────────────── */
/* ── Escape to close ──────────────────────────────────────────── */
document.addEventListener('keydown', (e) => {
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && document.getElementById('cosmetics-modal')?.classList.contains('active')) {
if (e.key === 'Escape' && document.getElementById('cosmetics-modal')?.classList.contains('active')) {
@@ -606,9 +606,8 @@ app.post('/internal/openre-events', require('./openre/mirror').webhookHandler);
@@ -606,9 +606,8 @@ app.post('/internal/openre-events', require('./openre/mirror').webhookHandler);
// Internal (server-to-server) routes — allow openvibe.network to call into this service
// Internal (server-to-server) routes — allow openvibe.network to call into this service
// Summary numbers for the Network's navigation service (ordering sites by real use).
// Summary numbers for the Network's navigation service (ordering sites by real use).
-// Internal key only; returns totals, never rows.
-app.get('/internal/analytics-summary', (req, res) => {
- if (!require('./net/internal-key').internalKeyOk(req)) return res.status(401).json({ ok: false });
+// A service token with live.analytics.read (loopback only); returns totals, never rows.
+app.get('/internal/analytics-summary', require('./net/service-guard').guardOrKey('live.analytics.read'), (req, res) => {
try {
try {
const days = Math.min(parseInt(req.query.days, 10) || 7, 90);
const days = Math.min(parseInt(req.query.days, 10) || 7, 90);
const st = analytics.getStats({ days }) || {};
const st = analytics.getStats({ days }) || {};
@@ -5,18 +5,12 @@ const router = express.Router();
@@ -5,18 +5,12 @@ const router = express.Router();
const config = require('../config');
const config = require('../config');
const db = require('../db/database');
const db = require('../db/database');
-const { internalKeyOk } = require('../net/internal-key');
+const { guardOrKey } = require('../net/service-guard');
-// Service-to-service only: loopback (nothing that came through nginx) and INTERNAL_API_KEY,
-// compared in constant time (server/net/internal-key.js).
-function requireInternalKey(req, res, next) {
- if (!internalKeyOk(req)) {
- return res.status(403).json({ error: 'Invalid or missing internal key' });
- }
- next();
-}
-
-router.use(requireInternalKey);
+// Service-to-service only, loopback only (nothing that came through nginx): each route checks the one capability it
+// performs on a Network service token (server/net/service-guard.js). The X-Internal-Key path is kept only while its
+// callers move to tokens (plan T2).
+const chatTablesGuard = guardOrKey('live.chat_mirror.write'); // OpenVibe.Chat's staged-table handoff (T3 deletes)
// CHAT_AUTHORITY=chat: OpenVibe.Chat caches users; a role or avatar pushed here reaches it at once.
// CHAT_AUTHORITY=chat: OpenVibe.Chat caches users; a role or avatar pushed here reaches it at once.
function notifyChat(userId) {
function notifyChat(userId) {
@@ -29,18 +23,18 @@ function notifyChat(userId) {
@@ -29,18 +23,18 @@ function notifyChat(userId) {
// POST /internal/chat-tables/:table { dual_read?: bool, reset_counters?: bool, authority?: 'live'|'chat',
// POST /internal/chat-tables/:table { dual_read?: bool, reset_counters?: bool, authority?: 'live'|'chat',
// by?: 'who', force?: true (back to 'live' with Chat down) } — the
// by?: 'who', force?: true (back to 'live' with Chat down) } — the
// flag, the counters, then the handoff
// flag, the counters, then the handoff
-router.get('/chat-tables', async (req, res) => {
+router.get('/chat-tables', chatTablesGuard, async (req, res) => {
try { res.json(await require('../chat/chat-tables').status()); } catch (err) { res.status(500).json({ error: err.message }); }
try { res.json(await require('../chat/chat-tables').status()); } catch (err) { res.status(500).json({ error: err.message }); }
});
});
// POST /internal/chat-tables/relay { paused: bool } — hold the relay to Chat (the capture goes on) for an import
// POST /internal/chat-tables/relay { paused: bool } — hold the relay to Chat (the capture goes on) for an import
-router.post('/chat-tables/relay', (req, res) => {
+router.post('/chat-tables/relay', chatTablesGuard, (req, res) => {
try {
try {
const sync = require('../chat/chat-tables-sync');
const sync = require('../chat/chat-tables-sync');
if (req.body && req.body.paused !== undefined) sync.setPaused(req.body.paused === true || req.body.paused === 'true');
if (req.body && req.body.paused !== undefined) sync.setPaused(req.body.paused === true || req.body.paused === 'true');
res.json({ ok: true, relay: sync.relayStats() });
res.json({ ok: true, relay: sync.relayStats() });
} catch (err) { res.status(500).json({ error: err.message }); }
} catch (err) { res.status(500).json({ error: err.message }); }
});
});
-router.post('/chat-tables/:table', async (req, res) => {
+router.post('/chat-tables/:table', chatTablesGuard, async (req, res) => {
const chatTables = require('../chat/chat-tables');
const chatTables = require('../chat/chat-tables');
const table = String(req.params.table);
const table = String(req.params.table);
const b = req.body || {};
const b = req.body || {};
@@ -61,7 +55,7 @@ router.post('/chat-tables/:table', async (req, res) => {
@@ -61,7 +55,7 @@ router.post('/chat-tables/:table', async (req, res) => {
// Footer site copy is written by OpenVibe.AI for the Network directly (network.site_copy); the
// Footer site copy is written by OpenVibe.AI for the Network directly (network.site_copy); the
// /internal/ai/site-copy fallback that used to live here was retired on 2026-09-23.
// /internal/ai/site-copy fallback that used to live here was retired on 2026-09-23.
-router.post('/url-registry/refresh', async (req, res) => {
+router.post('/url-registry/refresh', guardOrKey('live.url_registry.refresh'), async (req, res) => {
try {
try {
await config.refreshRegistry();
await config.refreshRegistry();
console.log('[Internal] URL registry refresh requested');
console.log('[Internal] URL registry refresh requested');
@@ -79,7 +73,7 @@ router.post('/url-registry/refresh', async (req, res) => {
@@ -79,7 +73,7 @@ router.post('/url-registry/refresh', async (req, res) => {
// ── The account's avatar changed on the Network (or on another site) ─────────
// ── The account's avatar changed on the Network (or on another site) ─────────
// The avatar is a network-wide property (OpenVibe.Network server/profile/avatar.js). Live keeps a copy on its
// The avatar is a network-wide property (OpenVibe.Network server/profile/avatar.js). Live keeps a copy on its
// own user row because every stream card, chat line and profile reads it locally.
// own user row because every stream card, chat line and profile reads it locally.
-router.post('/user-avatar', (req, res) => {
+router.post('/user-avatar', guardOrKey('live.avatar.write'), (req, res) => {
try {
try {
const { username, openvibenetwork_id, avatar_url } = req.body || {};
const { username, openvibenetwork_id, avatar_url } = req.body || {};
let url = null;
let url = null;
@@ -6,8 +6,6 @@
@@ -6,8 +6,6 @@
* GET /api/cosmetics/equipped/:userId - Public: get equipped cosmetics for a user
* GET /api/cosmetics/equipped/:userId - Public: get equipped cosmetics for a user
* POST /api/cosmetics/equip - Equip a cosmetic
* POST /api/cosmetics/equip - Equip a cosmetic
* POST /api/cosmetics/unequip - Unequip a slot
* POST /api/cosmetics/unequip - Unequip a slot
- * POST /api/cosmetics/activate - Consume game item → unlock cosmetic
- * POST /api/cosmetics/deactivate - Revoke cosmetic → return game item
*/
*/
const express = require('express');
const express = require('express');
const { requireAuth } = require('../auth/auth');
const { requireAuth } = require('../auth/auth');
@@ -60,43 +58,11 @@ router.post('/unequip', requireAuth, (req, res) => {
@@ -60,43 +58,11 @@ router.post('/unequip', requireAuth, (req, res) => {
res.json(result);
res.json(result);
});
});
-// ── Activate: consume game item → unlock cosmetic globally ───
-router.post('/activate', requireAuth, async (req, res) => {
- const { itemId } = req.body;
- if (!itemId) return res.status(400).json({ error: 'itemId required' });
- const result = await cosmetics.activateFromGame(req.user.id, itemId);
- if (result.error) return res.status(400).json(result);
- res.json(result);
-});
-
-// ── Deactivate: revoke cosmetic → add back to game inventory ─
-router.post('/deactivate', requireAuth, async (req, res) => {
- const { itemId } = req.body;
- if (!itemId) return res.status(400).json({ error: 'itemId required' });
- const result = await cosmetics.deactivateToGame(req.user.id, itemId);
- if (result.error) return res.status(400).json(result);
- res.json(result);
-});
-
-// ── Internal: auto-unlock cosmetic from openvibe-quest game ──────
-// Called server-to-server when openvibe-quest game awards a hat/cosmetic item.
-// Only the configured internal API key, from loopback: the legacy X-Internal-Secret is in git
-// history, and the quest game that sent it from this host is gone. The key is shared by several
-// services, so it is refused through the public edge (nginx/Cloudflare) like every internal route
-// (server/net/internal-key.js); otherwise anyone holding it could unlock paid cosmetics from outside.
-router.post('/internal-unlock', (req, res) => {
- if (!require('../net/internal-key').internalKeyOk(req)) {
- return res.status(403).json({ error: 'Forbidden' });
- }
- const { userId, itemId } = req.body;
- if (!userId || !itemId) return res.status(400).json({ error: 'userId and itemId required' });
- try {
- const result = cosmetics.unlockCosmetic(userId, itemId);
- res.json(result);
- } catch (err) {
- console.error(`[Cosmetics] internal-unlock error for user ${userId}, item ${itemId}:`, err.message);
- res.status(400).json({ error: 'Failed to unlock — user may not exist on openvibelive' });
- }
-});
+// The openvibe-quest game-item bridge is gone (plan T2; the quest game runs nowhere, so these always failed). Open
+// tabs of the previous release may still call them for one release (ADR-016 N-1): they answer 410 behind the same
+// auth, then the next release deletes both.
+for (const p of ['/activate', '/deactivate']) {
+ router.post(p, requireAuth, (req, res) => res.status(410).json({ error: 'Game items no longer convert to cosmetics' }));
+}
module.exports = router;
module.exports = router;
@@ -1,16 +1,10 @@
@@ -1,16 +1,10 @@
/**
/**
* OpenVibe.Live — Cosmetics System
* OpenVibe.Live — Cosmetics System
- * Global cosmetic items (name effects, particles, hats, voices)
- * Unlocked via OpenVibeGame items, equipped globally for chat/overlay.
- * Can be converted back to game items for trading.
+ * Global cosmetic items (name effects, particles, hats, voices), equipped globally for chat/overlay.
+ * (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 db = require('../db/database');
-const http = require('http');
-
-// openvibe-quest internal API base (server-to-server). The quest game no longer runs on this host
-// (2026-09-23), so the game-item bridge is off unless LEGACY_QUEST_API_URL names one; it
-// authenticates with INTERNAL_API_KEY, never a secret written into the code.
-const LEGACY_QUEST_API = String(process.env.LEGACY_QUEST_API_URL || '').replace(/\/+$/, '');
// ── Cosmetic Catalog (defines all cosmetics and their CSS/rendering data) ───
// ── Cosmetic Catalog (defines all cosmetics and their CSS/rendering data) ───
const COSMETICS = {
const COSMETICS = {
@@ -220,77 +214,6 @@ function unequipSlot(userId, slot) {
@@ -220,77 +214,6 @@ function unequipSlot(userId, slot) {
return { success: true, slot };
return { success: true, slot };
}
}
-// ── Helper: call openvibe-quest internal API ─────────────────────
-function questApi(method, path, body) {
- if (!LEGACY_QUEST_API) return Promise.reject(new Error('the quest game bridge is off (LEGACY_QUEST_API_URL is not set)'));
- return new Promise((resolve, reject) => {
- const data = body ? JSON.stringify(body) : null;
- const req = http.request(`${LEGACY_QUEST_API}${path}`, {
- method,
- headers: {
- 'Content-Type': 'application/json',
- 'X-Internal-Key': String(require('../config').internalApiKey || ''),
- ...(data ? { 'Content-Length': Buffer.byteLength(data) } : {}),
- },
- timeout: 5000,
- }, (res) => {
- let chunks = '';
- res.on('data', (c) => chunks += c);
- res.on('end', () => {
- try { resolve(JSON.parse(chunks)); } catch { resolve({}); }
- });
- });
- req.on('error', reject);
- req.on('timeout', () => { req.destroy(); reject(new Error('openvibe-quest timeout')); });
- if (data) req.write(data);
- req.end();
- });
-}
-
-// ── Activate: consume game item → unlock global cosmetic ─────
-async function activateFromGame(userId, itemId) {
- const cosmetic = COSMETICS[itemId];
- if (!cosmetic) return { error: 'This item has no global cosmetic' };
- if (ownsCosmetic(userId, itemId)) return { error: 'Already unlocked globally' };
-
- // Ask openvibe-quest to check & consume game inventory item
- try {
- const result = await questApi('POST', '/api/internal/inventory/consume', { userId, itemId, quantity: 1 });
- if (result.error) return { error: result.error };
- } catch {
- return { error: 'Game server unavailable — try again later' };
- }
-
- // Unlock the cosmetic in openvibelive's DB
- const d = db.getDb();
- d.prepare('INSERT OR IGNORE INTO user_cosmetics (user_id, item_id, category) VALUES (?, ?, ?)').run(userId, itemId, cosmetic.category);
- return { success: true, message: `Unlocked ${cosmetic.name} globally!`, item: cosmetic };
-}
-
-// ── Deactivate: revoke global cosmetic → add back to game inventory ──
-async function deactivateToGame(userId, itemId) {
- const cosmetic = COSMETICS[itemId];
- if (!cosmetic) return { error: 'Unknown cosmetic' };
- if (!ownsCosmetic(userId, itemId)) return { error: 'You don\'t own this cosmetic' };
-
- // Add item back to openvibe-quest game inventory
- try {
- const result = await questApi('POST', '/api/internal/inventory/add', { userId, itemId, quantity: 1 });
- if (result.error) return { error: result.error };
- } catch {
- return { error: 'Game server unavailable — try again later' };
- }
-
- const d = db.getDb();
- // Unequip if equipped
- const slot = CATEGORY_SLOT[cosmetic.category];
- d.prepare('DELETE FROM user_equipped WHERE user_id = ? AND slot = ? AND item_id = ?').run(userId, slot, itemId);
- // Remove from cosmetics
- d.prepare('DELETE FROM user_cosmetics WHERE user_id = ? AND item_id = ?').run(userId, itemId);
-
- return { success: true, message: `Converted ${cosmetic.name} back to game item` };
-}
-
// ── Get full inventory + equipped for UI ─────────────────────
// ── Get full inventory + equipped for UI ─────────────────────
function getFullInventory(userId) {
function getFullInventory(userId) {
const unlocked = getUnlocked(userId);
const unlocked = getUnlocked(userId);
@@ -323,7 +246,5 @@ module.exports = {
@@ -323,7 +246,5 @@ module.exports = {
revokeCosmetic,
revokeCosmetic,
equipCosmetic,
equipCosmetic,
unequipSlot,
unequipSlot,
- activateFromGame,
- deactivateToGame,
getFullInventory,
getFullInventory,
};
};
@@ -7,10 +7,8 @@
@@ -7,10 +7,8 @@
* loopback callers never send them. Internal routes refuse such a request.
* loopback callers never send them. Internal routes refuse such a request.
* internalKeyMatches(k) timing-safe comparison with INTERNAL_API_KEY (both sides hashed first, so
* internalKeyMatches(k) timing-safe comparison with INTERNAL_API_KEY (both sides hashed first, so
* neither the content nor the length leaks through timing).
* neither the content nor the length leaks through timing).
- * internalKeyOk(req) both: loopback and the right X-Internal-Key. Every X-Internal-Key route uses
- * this (server/internal/routes.js, /internal/analytics-summary,
- * POST /api/cosmetics/internal-unlock); service-token routes use
- * server/net/service-guard.js, which applies the same viaProxy rule.
+ * internalKeyOk(req) both: loopback and the right X-Internal-Key. Only service-guard.js guardOrKey
+ * uses it, for callers still moving to service tokens (plan T2 deletes it).
*/
*/
const crypto = require('crypto');
const crypto = require('crypto');
@@ -38,4 +38,18 @@ function guard(capability) {
@@ -38,4 +38,18 @@ function guard(capability) {
};
};
}
}
-module.exports = { guard, AUDIENCE };
+/**
+ * While X-Internal-Key is retired (plan T2): a request that presents a Bearer is judged on the token alone (never
+ * downgraded to the key); one without a Bearer still passes with the key from loopback. Callers move to tokens, then
+ * every route becomes guard(capability) and the key goes.
+ */
+function guardOrKey(capability) {
+ const tokenGuard = guard(capability);
+ return function liveServiceGuardOrKey(req, res, next) {
+ if (String(req.headers.authorization || '').startsWith('Bearer ')) return tokenGuard(req, res, next);
+ if (require('./internal-key').internalKeyOk(req)) return next();
+ return http.sendProblem(res, 401, 'token.missing', { detail: 'a service token is required', ctx: http.requestContext(req.headers) });
+ };
+}
+
+module.exports = { guard, guardOrKey, AUDIENCE };
@@ -286,8 +286,8 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -286,8 +286,8 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
assert.strictEqual((await internal('POST', '/chat-tables/channel_moderation_settings', { reset_counters: true })).body.table.dual_read_stats.compared, 0);
assert.strictEqual((await internal('POST', '/chat-tables/channel_moderation_settings', { reset_counters: true })).body.table.dual_read_stats.compared, 0);
// 6. The status route: loopback + internal key; both sides' view of every table.
// 6. The status route: loopback + internal key; both sides' view of every table.
- assert.strictEqual((await call('GET', '/internal/chat-tables')).status, 403);
- assert.strictEqual((await call('GET', '/internal/chat-tables', { headers: { ...KEY, 'X-Forwarded-For': '203.0.113.1' } })).status, 403);
+ assert.strictEqual((await call('GET', '/internal/chat-tables')).status, 401, 'no credentials');
+ assert.strictEqual((await call('GET', '/internal/chat-tables', { headers: { ...KEY, 'X-Forwarded-For': '203.0.113.1' } })).status, 401, 'the key through the public edge');
let s = (await internal('GET', '/chat-tables')).body;
let s = (await internal('GET', '/chat-tables')).body;
assert.strictEqual(s.chat_authority, 'chat');
assert.strictEqual(s.chat_authority, 'chat');
assert.deepStrictEqual([s.tables.emotes.authority, s.tables.emotes.chat_authority, s.tables.emotes.dual_read, s.tables.emotes.outbox_pending], ['live', 'live', true, 0]);
assert.deepStrictEqual([s.tables.emotes.authority, s.tables.emotes.chat_authority, s.tables.emotes.dual_read, s.tables.emotes.outbox_pending], ['live', 'live', true, 0]);
@@ -1,27 +1,38 @@
@@ -1,27 +1,38 @@
'use strict';
'use strict';
-// POST /api/cosmetics/internal-unlock grants paid cosmetics, so only the configured INTERNAL_API_KEY
-// opens it, and only from loopback. The legacy X-Internal-Secret is in the repository's history:
-// from loopback, without proxy headers, it used to be enough on its own. The key is shared by
-// Network, Live, Media and Tools, so a request that came through the public edge (nginx adds
-// X-Forwarded-For / X-Real-IP, Cloudflare CF-Connecting-IP) is refused even with the right key.
-// The same rule and a constant-time compare cover the other X-Internal-Key routes
-// (server/net/internal-key.js): /internal/* and /internal/analytics-summary.
+// Live's internal (server-to-server) routes, during the X-Internal-Key retirement (plan T2):
+// - each route checks the one capability it performs on a Network service token (server/net/service-guard.js);
+// - a request that presents a Bearer is judged on the token alone, never downgraded to the key;
+// - without a Bearer, the key still passes from loopback while callers move (guardOrKey), compared in constant time;
+// - nothing that came through nginx or Cloudflare gets in, with a token or the key;
+// - POST /api/cosmetics/internal-unlock (the openvibe-quest bridge) is gone: the quest game runs nowhere.
const assert = require('assert');
const assert = require('assert');
+const crypto = require('crypto');
const fs = require('fs');
const fs = require('fs');
const os = require('os');
const os = require('os');
const path = require('path');
const path = require('path');
const express = require('express');
const express = require('express');
+const { serviceAuth } = require('openvibe-contracts');
-const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-cosmetics-internal-'));
+const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-live-internal-'));
+const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
+fs.writeFileSync(path.join(tmp, 'network.pem'), keys.publicKey);
process.env.DB_PATH = path.join(tmp, 'live.db');
process.env.DB_PATH = path.join(tmp, 'live.db');
+process.env.OV_NETWORK_PUBLIC_KEY = path.join(tmp, 'network.pem');
+process.env.OV_NETWORK_URL = 'https://openvibe.network';
const KEY = 'k'.repeat(40);
const KEY = 'k'.repeat(40);
process.env.INTERNAL_API_KEY = KEY;
process.env.INTERNAL_API_KEY = KEY;
console.log = () => {};
console.log = () => {};
console.warn = () => {};
console.warn = () => {};
console.error = () => {};
console.error = () => {};
+const now = () => Math.floor(Date.now() / 1000);
+let jti = 0;
+const token = (cap, { aud = 'openvibe.live', sub = 'svc:network', env } = {}) => serviceAuth.signServiceToken({
+ iss: 'https://openvibe.network', sub, actor_type: 'service', aud: [aud], cap, iat: now(), exp: now() + 300, jti: `tok_test_${String(++jti).padStart(4, '0')}`, ...(env ? { env } : {}),
+}, keys.privateKey);
+
(async () => {
(async () => {
require('../server/db/database').initDb();
require('../server/db/database').initDb();
const app = express();
const app = express();
@@ -32,32 +43,46 @@ console.error = () => {};
@@ -32,32 +43,46 @@ console.error = () => {};
await new Promise((r) => server.once('listening', r));
await new Promise((r) => server.once('listening', r));
const origin = `http://127.0.0.1:${server.address().port}`;
const origin = `http://127.0.0.1:${server.address().port}`;
const post = (p, headers, body = {}) => fetch(origin + p, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body: JSON.stringify(body) });
const post = (p, headers, body = {}) => fetch(origin + p, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body: JSON.stringify(body) });
- const unlock = (headers) => post('/api/cosmetics/internal-unlock', headers);
+ const bearer = (t) => ({ authorization: `Bearer ${t}` });
try {
try {
- // ── internal-unlock ──────────────────────────────────────────────────────────
- assert.strictEqual((await unlock({ 'x-internal-secret': 'openvibe-internal-2026' })).status, 403, 'the legacy secret from loopback no longer opens it');
- assert.strictEqual((await unlock({})).status, 403);
- assert.strictEqual((await unlock({ 'x-internal-key': 'wrong' })).status, 403);
- assert.strictEqual((await unlock({ 'x-internal-key': 'j'.repeat(40) })).status, 403, 'a wrong key of the right length');
- assert.strictEqual((await unlock({ 'x-internal-key': KEY + 'x' })).status, 403, 'a longer key that starts right');
- assert.strictEqual((await unlock({ 'x-internal-key': KEY })).status, 400, 'the internal key from loopback gets past the gate (then needs userId/itemId)');
- for (const h of ['x-forwarded-for', 'x-real-ip', 'cf-connecting-ip']) {
- assert.strictEqual((await unlock({ 'x-internal-key': KEY, [h]: '203.0.113.9' })).status, 403, `the right key through the public edge (${h}) is refused`);
+ // ── the quest bridge is gone ─────────────────────────────────────────────────
+ for (const h of [{ 'x-internal-key': KEY }, { 'x-internal-secret': 'openvibe-internal-2026' }]) {
+ assert.strictEqual((await post('/api/cosmetics/internal-unlock', h, { userId: 1, itemId: 'fx_fire' })).status, 404, 'internal-unlock no longer exists');
}
}
+ // activate/deactivate answer 410 for one release (old tabs, ADR-016), behind the same auth.
+ for (const p of ['/api/cosmetics/activate', '/api/cosmetics/deactivate']) assert.strictEqual((await post(p, {}, { itemId: 'fx_fire' })).status, 401, `${p} still needs a session`);
- // ── the other X-Internal-Key routes: same rule ──────────────────────────────
+ // ── /internal/user-avatar: live.avatar.write ─────────────────────────────────
const avatar = (headers) => post('/internal/user-avatar', headers, { username: 'nobody-here' });
const avatar = (headers) => post('/internal/user-avatar', headers, { username: 'nobody-here' });
- assert.strictEqual((await avatar({ 'x-internal-key': KEY })).status, 404, 'the right key from loopback reaches /internal/*');
- assert.strictEqual((await avatar({ 'x-internal-key': 'j'.repeat(40) })).status, 403);
- assert.strictEqual((await avatar({ 'x-internal-key': KEY, 'x-forwarded-for': '203.0.113.9' })).status, 403);
- assert.strictEqual((await avatar({ 'x-internal-key': KEY, 'cf-connecting-ip': '203.0.113.9' })).status, 403);
+ assert.strictEqual((await avatar(bearer(token(['live.avatar.write'])))).status, 404, 'the capability reaches the handler (no such user)');
+ assert.strictEqual((await avatar(bearer(token(['live.url_registry.refresh'])))).status, 403, 'another capability is not enough');
+ assert.strictEqual((await avatar(bearer(token(['live.avatar.write'], { aud: 'openvibe.network' })))).status, 401, 'a token for another audience');
+ assert.strictEqual((await avatar(bearer(token(['live.avatar.write'], { env: 'sandbox' })))).status, 401, 'a sandbox token');
+ assert.strictEqual((await avatar({ ...bearer('not.a.token'), 'x-internal-key': KEY })).status, 401, 'a Bearer is judged on the token alone, never downgraded to the key');
+ assert.strictEqual((await avatar({ ...bearer(token(['live.avatar.write'])), 'x-forwarded-for': '203.0.113.9' })).status, 403, 'a token through the public edge is refused');
+ assert.strictEqual((await avatar({})).status, 401);
+ // The key while callers move: loopback only, constant time.
+ assert.strictEqual((await avatar({ 'x-internal-key': KEY })).status, 404, 'the key from loopback still reaches the handler');
+ assert.strictEqual((await avatar({ 'x-internal-key': 'j'.repeat(40) })).status, 401, 'a wrong key of the right length');
+ assert.strictEqual((await avatar({ 'x-internal-key': KEY + 'x' })).status, 401, 'a longer key that starts right');
+ for (const h of ['x-forwarded-for', 'x-real-ip', 'cf-connecting-ip']) {
+ assert.strictEqual((await avatar({ 'x-internal-key': KEY, [h]: '203.0.113.9' })).status, 401, `the right key through the public edge (${h}) is refused`);
+ }
+
+ // ── the other internal routes: each has its own capability ───────────────────
+ assert.strictEqual((await post('/internal/url-registry/refresh', bearer(token(['live.avatar.write'])))).status, 403);
+ assert.notStrictEqual((await post('/internal/url-registry/refresh', bearer(token(['live.url_registry.refresh'])))).status, 403, 'live.url_registry.refresh opens the refresh');
+ const tables = (headers) => fetch(`${origin}/internal/chat-tables`, { headers });
+ assert.strictEqual((await tables(bearer(token(['live.avatar.write'], { sub: 'svc:chat' })))).status, 403);
+ assert.notStrictEqual((await tables(bearer(token(['live.chat_mirror.write'], { sub: 'svc:chat' })))).status, 403, 'Chat opens the staged-table handoff with live.chat_mirror.write');
+ assert.strictEqual((await tables({})).status, 401);
const ik = require('../server/net/internal-key');
const ik = require('../server/net/internal-key');
assert.strictEqual(ik.internalKeyMatches(KEY), true);
assert.strictEqual(ik.internalKeyMatches(KEY), true);
assert.strictEqual(ik.internalKeyMatches(''), false);
assert.strictEqual(ik.internalKeyMatches(''), false);
assert.strictEqual(ik.internalKeyMatches(KEY.slice(0, 39)), false);
assert.strictEqual(ik.internalKeyMatches(KEY.slice(0, 39)), false);
- // No internal-key compare with === / !== is left anywhere in the server.
+ // No internal-key compare with === / !== anywhere in the server.
const offenders = [];
const offenders = [];
const walk = (dir) => {
const walk = (dir) => {
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
@@ -71,7 +96,7 @@ console.error = () => {};
@@ -71,7 +96,7 @@ console.error = () => {};
};
};
walk(path.join(__dirname, '..', 'server'));
walk(path.join(__dirname, '..', 'server'));
assert.deepStrictEqual(offenders, [], 'internal keys are compared with internalKeyMatches (constant time)');
assert.deepStrictEqual(offenders, [], 'internal keys are compared with internalKeyMatches (constant time)');
- process.stdout.write('cosmetics internal-unlock: all checks passed\n');
+ process.stdout.write('live internal routes: all checks passed\n');
} finally {
} finally {
server.close();
server.close();
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(tmp, { recursive: true, force: true });