openvibers/openvibe.live Commits
Remove the hard-coded legacy quest secret: /api/cosmetics/internal-unlock takes only INTERNAL_API_KEY, and the quest game-item bridge is off unless LEGACY_QUEST_API_URL is set (the game no longer runs); drop the unused admin route that wrote the frozen pastes table (Wave 22 register C-59, C-73)
Changes
@@@ -47,6 +47,8 @@ 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@@@ -423,17 +423,6 @@ router.put('/streams/:id/nsfw', (req, res) => {}});--// ── Force NSFW on a Paste ────────────────────────────────────--router.put('/pastes/:id/nsfw', (req, res) => {-- try {-- const { is_nsfw } = req.body;-- db.run('UPDATE pastes SET is_nsfw = ? WHERE id = ?', [is_nsfw ? 1 : 0, req.params.id]);-- res.json({ message: is_nsfw ? 'Paste marked as NSFW' : 'NSFW removed from paste' });-- } catch (err) {-- res.status(500).json({ error: 'Failed to update paste NSFW' });-- }--});--// ── List Bans ────────────────────────────────────────────────router.get('/bans', (req, res) => {try {@@@ -80,17 +80,13 @@ router.post('/deactivate', requireAuth, async (req, res) => {// ── Internal: auto-unlock cosmetic from openvibe-quest game ──────// Called server-to-server when openvibe-quest game awards a hat/cosmetic item.--// The shared secret in the X-Internal-Secret header is in git history, so on its own it proves--// nothing. Accept either the configured internal API key, or the legacy header from loopback only--// (the game ran on this host). Anyone on the internet could otherwise unlock paid cosmetics.++// Only the configured internal API key: the legacy X-Internal-Secret is in git history, and the++// quest game that sent it from this host is gone. Anyone else could unlock paid cosmetics.function internalCallerOk(req) {const crypto = require('crypto');const key = String(require('../config').internalApiKey || '');const given = String(req.headers['x-internal-key'] || '');-- if (key && given.length === key.length && crypto.timingSafeEqual(Buffer.from(given), Buffer.from(key))) return true;-- const loopback = /^(127\.|::1$|::ffff:127\.)/.test(String(req.socket && req.socket.remoteAddress || ''));-- const forwarded = !!(req.headers['x-forwarded-for'] || req.headers['cf-connecting-ip']);-- return loopback && !forwarded && req.headers['x-internal-secret'] === 'openvibe-internal-2026';++ return !!key && given.length === key.length && crypto.timingSafeEqual(Buffer.from(given), Buffer.from(key));}router.post('/internal-unlock', (req, res) => {if (!internalCallerOk(req)) {@@@ -7,8 +7,10 @@const db = require('../db/database');const http = require('http');--// openvibe-quest internal API base (server-to-server on localhost)--const LEGACY_QUEST_API = 'http://127.0.0.1:3200';++// 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,13 +222,14 @@ function unequipSlot(userId, 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-Secret': 'openvibe-internal-2026',++ 'X-Internal-Key': String(require('../config').internalApiKey || ''),...(data ? { 'Content-Length': Buffer.byteLength(data) } : {}),},timeout: 5000,nnew file mode 100644@@@ -0,0 +1,38 @@++'use strict';++++// POST /api/cosmetics/internal-unlock grants paid cosmetics, so only the configured INTERNAL_API_KEY++// opens it. The legacy X-Internal-Secret is in git history: from loopback, without proxy headers,++// it used to be enough on its own.++++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const express = require('express');++++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-cosmetics-internal-'));++process.env.DB_PATH = path.join(tmp, 'live.db');++process.env.INTERNAL_API_KEY = 'k'.repeat(40);++console.log = () => {};++console.warn = () => {};++console.error = () => {};++++(async () => {++ const app = express();++ app.use(express.json());++ app.use('/api/cosmetics', require('../server/monetization/cosmetics-routes'));++ const server = app.listen(0, '127.0.0.1');++ await new Promise((r) => server.once('listening', r));++ const base = `http://127.0.0.1:${server.address().port}/api/cosmetics/internal-unlock`;++ const post = (headers) => fetch(base, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body: JSON.stringify({}) });++ try {++ assert.strictEqual((await post({ 'x-internal-secret': 'openvibe-internal-2026' })).status, 403, 'the legacy secret from loopback no longer opens it');++ assert.strictEqual((await post({})).status, 403);++ assert.strictEqual((await post({ 'x-internal-key': 'wrong' })).status, 403);++ assert.strictEqual((await post({ 'x-internal-key': 'k'.repeat(40) })).status, 400, 'the internal key gets past the gate (then needs userId/itemId)');++ process.stdout.write('cosmetics internal-unlock: all checks passed\n');++ } finally {++ server.close();++ fs.rmSync(tmp, { recursive: true, force: true });++ }++})().catch((e) => { process.stderr.write(String(e && e.stack || e) + '\n'); process.exit(1); });
@@ -47,6 +47,8 @@ OV_OAUTH_CLIENT_ID=live
@@ -47,6 +47,8 @@ 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
@@ -423,17 +423,6 @@ router.put('/streams/:id/nsfw', (req, res) => {
@@ -423,17 +423,6 @@ router.put('/streams/:id/nsfw', (req, res) => {
}
}
});
});
-// ── Force NSFW on a Paste ────────────────────────────────────
-router.put('/pastes/:id/nsfw', (req, res) => {
- try {
- const { is_nsfw } = req.body;
- db.run('UPDATE pastes SET is_nsfw = ? WHERE id = ?', [is_nsfw ? 1 : 0, req.params.id]);
- res.json({ message: is_nsfw ? 'Paste marked as NSFW' : 'NSFW removed from paste' });
- } catch (err) {
- res.status(500).json({ error: 'Failed to update paste NSFW' });
- }
-});
-
// ── List Bans ────────────────────────────────────────────────
// ── List Bans ────────────────────────────────────────────────
router.get('/bans', (req, res) => {
router.get('/bans', (req, res) => {
try {
try {
@@ -80,17 +80,13 @@ router.post('/deactivate', requireAuth, async (req, res) => {
@@ -80,17 +80,13 @@ router.post('/deactivate', requireAuth, async (req, res) => {
// ── Internal: auto-unlock cosmetic from openvibe-quest game ──────
// ── Internal: auto-unlock cosmetic from openvibe-quest game ──────
// Called server-to-server when openvibe-quest game awards a hat/cosmetic item.
// Called server-to-server when openvibe-quest game awards a hat/cosmetic item.
-// The shared secret in the X-Internal-Secret header is in git history, so on its own it proves
-// nothing. Accept either the configured internal API key, or the legacy header from loopback only
-// (the game ran on this host). Anyone on the internet could otherwise unlock paid cosmetics.
+// Only the configured internal API key: the legacy X-Internal-Secret is in git history, and the
+// quest game that sent it from this host is gone. Anyone else could unlock paid cosmetics.
function internalCallerOk(req) {
function internalCallerOk(req) {
const crypto = require('crypto');
const crypto = require('crypto');
const key = String(require('../config').internalApiKey || '');
const key = String(require('../config').internalApiKey || '');
const given = String(req.headers['x-internal-key'] || '');
const given = String(req.headers['x-internal-key'] || '');
- if (key && given.length === key.length && crypto.timingSafeEqual(Buffer.from(given), Buffer.from(key))) return true;
- const loopback = /^(127\.|::1$|::ffff:127\.)/.test(String(req.socket && req.socket.remoteAddress || ''));
- const forwarded = !!(req.headers['x-forwarded-for'] || req.headers['cf-connecting-ip']);
- return loopback && !forwarded && req.headers['x-internal-secret'] === 'openvibe-internal-2026';
+ return !!key && given.length === key.length && crypto.timingSafeEqual(Buffer.from(given), Buffer.from(key));
}
}
router.post('/internal-unlock', (req, res) => {
router.post('/internal-unlock', (req, res) => {
if (!internalCallerOk(req)) {
if (!internalCallerOk(req)) {
@@ -7,8 +7,10 @@
@@ -7,8 +7,10 @@
const db = require('../db/database');
const db = require('../db/database');
const http = require('http');
const http = require('http');
-// openvibe-quest internal API base (server-to-server on localhost)
-const LEGACY_QUEST_API = 'http://127.0.0.1:3200';
+// 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,13 +222,14 @@ function unequipSlot(userId, slot) {
@@ -220,13 +222,14 @@ function unequipSlot(userId, slot) {
// ── Helper: call openvibe-quest internal API ─────────────────────
// ── Helper: call openvibe-quest internal API ─────────────────────
function questApi(method, path, body) {
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) => {
return new Promise((resolve, reject) => {
const data = body ? JSON.stringify(body) : null;
const data = body ? JSON.stringify(body) : null;
const req = http.request(`${LEGACY_QUEST_API}${path}`, {
const req = http.request(`${LEGACY_QUEST_API}${path}`, {
method,
method,
headers: {
headers: {
'Content-Type': 'application/json',
'Content-Type': 'application/json',
- 'X-Internal-Secret': 'openvibe-internal-2026',
+ 'X-Internal-Key': String(require('../config').internalApiKey || ''),
...(data ? { 'Content-Length': Buffer.byteLength(data) } : {}),
...(data ? { 'Content-Length': Buffer.byteLength(data) } : {}),
},
},
timeout: 5000,
timeout: 5000,
new file mode 100644
new file mode 100644
@@ -0,0 +1,38 @@
@@ -0,0 +1,38 @@
+'use strict';
+
+// POST /api/cosmetics/internal-unlock grants paid cosmetics, so only the configured INTERNAL_API_KEY
+// opens it. The legacy X-Internal-Secret is in git history: from loopback, without proxy headers,
+// it used to be enough on its own.
+
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const express = require('express');
+
+const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-cosmetics-internal-'));
+process.env.DB_PATH = path.join(tmp, 'live.db');
+process.env.INTERNAL_API_KEY = 'k'.repeat(40);
+console.log = () => {};
+console.warn = () => {};
+console.error = () => {};
+
+(async () => {
+ const app = express();
+ app.use(express.json());
+ app.use('/api/cosmetics', require('../server/monetization/cosmetics-routes'));
+ const server = app.listen(0, '127.0.0.1');
+ await new Promise((r) => server.once('listening', r));
+ const base = `http://127.0.0.1:${server.address().port}/api/cosmetics/internal-unlock`;
+ const post = (headers) => fetch(base, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body: JSON.stringify({}) });
+ try {
+ assert.strictEqual((await post({ 'x-internal-secret': 'openvibe-internal-2026' })).status, 403, 'the legacy secret from loopback no longer opens it');
+ assert.strictEqual((await post({})).status, 403);
+ assert.strictEqual((await post({ 'x-internal-key': 'wrong' })).status, 403);
+ assert.strictEqual((await post({ 'x-internal-key': 'k'.repeat(40) })).status, 400, 'the internal key gets past the gate (then needs userId/itemId)');
+ process.stdout.write('cosmetics internal-unlock: all checks passed\n');
+ } finally {
+ server.close();
+ fs.rmSync(tmp, { recursive: true, force: true });
+ }
+})().catch((e) => { process.stderr.write(String(e && e.stack || e) + '\n'); process.exit(1); });