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.

OP OpenVibers committed 29 Sept 2026 · 660833de2a · 12 files, +91 −220


Changes

ddiff --git a/.env.example b/.env.example
iindex 7fe8428b..b8ef36f4 100644
---- a/.env.example
++++ b/.env.example
@@@ -57,8 +57,6 @@ OV_OAUTH_CLIENT_ID=live
OV_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
ddiff --git a/public/css/cosmetics.css b/public/css/cosmetics.css
iindex f14ec00f..ded87aa9 100644
---- a/public/css/cosmetics.css
++++ b/public/css/cosmetics.css
@@@ -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 {
ddiff --git a/public/fragments/documentation.html b/public/fragments/documentation.html
iindex a46743d0..6e74ae99 100644
---- a/public/fragments/documentation.html
++++ b/public/fragments/documentation.html
@@@ -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 &middot; /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 &middot; /unequip &middot; /activate &middot; /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 &middot; /unequip</td><td>Yes</td><td>Equip a slot / consume a game item to unlock / revoke</td></tr>
</tbody>
</table>
</div>
ddiff --git a/public/js/cosmetics-ui.js b/public/js/cosmetics-ui.js
iindex f5cf5a3f..0cef14f5 100644
---- a/public/js/cosmetics-ui.js
++++ b/public/js/cosmetics-ui.js
@@@ -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')) {
ddiff --git a/server/index.js b/server/index.js
iindex 453e99b9..ae699e00 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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 }) || {};
ddiff --git a/server/internal/routes.js b/server/internal/routes.js
iindex 114f5d6a..a38a464f 100644
---- a/server/internal/routes.js
++++ b/server/internal/routes.js
@@@ -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;
ddiff --git a/server/monetization/cosmetics-routes.js b/server/monetization/cosmetics-routes.js
iindex 419296a4..29651484 100644
---- a/server/monetization/cosmetics-routes.js
++++ b/server/monetization/cosmetics-routes.js
@@@ -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;
ddiff --git a/server/monetization/cosmetics.js b/server/monetization/cosmetics.js
iindex 927eb178..5ffd3ae6 100644
---- a/server/monetization/cosmetics.js
++++ b/server/monetization/cosmetics.js
@@@ -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,
};
ddiff --git a/server/net/internal-key.js b/server/net/internal-key.js
iindex cb35dff3..fcddf624 100644
---- a/server/net/internal-key.js
++++ b/server/net/internal-key.js
@@@ -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');
ddiff --git a/server/net/service-guard.js b/server/net/service-guard.js
iindex 8fb85e81..56fb69d8 100644
---- a/server/net/service-guard.js
++++ b/server/net/service-guard.js
@@@ -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 };
ddiff --git a/test/chat-tables.test.js b/test/chat-tables.test.js
iindex cb493d70..94a0e94d 100644
---- a/test/chat-tables.test.js
++++ b/test/chat-tables.test.js
@@@ -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]);
ddiff --git a/test/cosmetics-internal.test.js b/test/cosmetics-internal.test.js
iindex 4488ceff..1eb6d8d4 100644
---- a/test/cosmetics-internal.test.js
++++ b/test/cosmetics-internal.test.js
@@@ -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 });