openvibers/openvibe.live Commits

Channel links carry the @ everywhere: Live's own go-live push (golive-notify.js) and the AI 'caught live' pastes (stream-memory-job.js) still built https://openvibe.live/<username>, which is a 404 by rule. test/channel-links.test.js scans server/ and public/js for a username placed straight after an origin or base-URL expression without the @ (it catches the shapes Live used; the four allowed lines are PowerChat's own tip pages and relay API paths, each with its reason)

OP OpenVibers committed 24 Sept 2026 · 4585f613a4 · 3 files, +57 −2


Changes

ddiff --git a/server/ai/stream-memory-job.js b/server/ai/stream-memory-job.js
iindex edf9e39c..96764afb 100644
---- a/server/ai/stream-memory-job.js
++++ b/server/ai/stream-memory-job.js
@@@ -200,7 +200,7 @@ async function _maybeLivePaste(stream, image, r, offset) {
// AI output is never filed under a person (roadmap 33): origin 'ai', the stream rides along as its source.
const paste = await require('../pastes-client').createPaste({
slug: moments.makeSlug ? moments.makeSlug() : undefined, user_id: stream.user_id,
-- title: r.title.slice(0, 80), content: `${r.description}\n\n🔴 Caught live on @${username}'s stream — ${base}/${username}`, language: 'text', visibility: 'public',
++ title: r.title.slice(0, 80), content: `${r.description}\n\n🔴 Caught live on @${username}'s stream — ${base}/@${encodeURIComponent(username)}`, language: 'text', visibility: 'public',
stream_id: stream.id, metadata: JSON.stringify({ ai_moment: true, live: true, stream_id: stream.id, offset, username, vod_link: null }),
ai_summary: r.description, ai_tags: JSON.stringify(Array.isArray(r.tags) ? r.tags.slice(0, 8) : []),
screenshot: { buffer, filename: `live-${stream.id}-${offset}.jpg`, contentType: 'image/jpeg' },
ddiff --git a/server/streaming/golive-notify.js b/server/streaming/golive-notify.js
iindex d61ace53..53f1e515 100644
---- a/server/streaming/golive-notify.js
++++ b/server/streaming/golive-notify.js
@@@ -16,7 +16,7 @@ const { notifyDiscordGoLive } = require('../integrations/discord-webhook');
const DEDUPE_MS = 60 * 60 * 1000;
const _recent = new Map(); // `${userId}:${slot}` → ts
--function _channelUrl(streamer) { return `${(config.baseUrl || 'https://openvibe.live').replace(/\/$/, '')}/${streamer.username}`; }
++function _channelUrl(streamer) { return `${(config.baseUrl || 'https://openvibe.live').replace(/\/$/, '')}/@${encodeURIComponent(streamer.username)}`; }
function notifyFollowersGoLive(streamer, stream, { force = false } = {}) {
if (!streamer || !streamer.id) return;
ddiff --git a/test/channel-links.test.js b/test/channel-links.test.js
nnew file mode 100644
iindex 00000000..9f0dc5d9
---- /dev/null
++++ b/test/channel-links.test.js
@@@ -0,0 +1,55 @@
++'use strict';
++
++// A channel's page is only ever /@<username> (owner rule, 2026-09-23: a bare /<username> is a 404 and
++// never redirects, so usernames and page routes can never compete). This scans Live's server and
++// client code for links that put a username straight after the site's origin or a base-URL
++// expression without the @, the shape go-live notifications, Discord posts and AI pastes used.
++
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++
++const ROOT = path.join(__dirname, '..');
++const DIRS = ['server', 'public/js'];
++// `${base}/${username}` or `openvibe.live/${user.username}` (optionally encodeURIComponent/esc-wrapped).
++const BARE = /(?:\}|openvibe\.live)\/\$\{(?:encodeURIComponent\(|esc\()?[\w.?]*(?:[uU]sername|handle)\)?\}/g;
++
++// Every entry names a line whose path is a real route, not a channel page, and why.
++const ALLOWED = [
++ { file: 'server/integrations/powerchat-checkout.js', contains: 'pcUsername', why: "PowerChat's own tip page (powerchat.live/<user>/tip), not an OpenVibe channel" },
++ { file: 'server/integrations/powerchat-routes.js', contains: 'powerchat_username', why: "PowerChat's own tip page" },
++ { file: 'public/js/app.js', contains: '/chat-ai/relay/', why: 'API path /chat-ai/relay/<platform>/<username>' },
++ { file: 'public/js/chat.js', contains: '/chat/relay-user/', why: 'API path /chat/relay-user/<platform>/<username>' },
++];
++
++function* files(dir) {
++ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
++ const p = path.join(dir, e.name);
++ if (e.isDirectory()) { if (e.name !== 'node_modules' && e.name !== 'vendor') yield* files(p); }
++ else if (e.name.endsWith('.js')) yield p;
++ }
++}
++
++const found = [];
++for (const d of DIRS) {
++ for (const f of files(path.join(ROOT, d))) {
++ const lines = fs.readFileSync(f, 'utf8').split('\n');
++ lines.forEach((line, i) => {
++ for (const m of line.matchAll(BARE)) {
++ const where = `${path.relative(ROOT, f)}:${i + 1}`;
++ if (!ALLOWED.some(a => where.startsWith(a.file) && line.includes(a.contains))) found.push(`${where} ${m[0]}`);
++ }
++ });
++ }
++}
++
++// The scanner itself catches the shapes Live used.
++for (const bad of ['${base}/${username}', '`https://openvibe.live/${streamer.username}`', '${cfg.baseUrl}/${encodeURIComponent(u.username)}']) {
++ assert.ok(bad.match(BARE), `the scanner catches ${bad}`);
++}
++for (const good of ['${base}/@${username}', '/api/users/${username}', '/@${encodeURIComponent(username)}']) {
++ assert.ok(!good.match(BARE), `the scanner leaves ${good} alone`);
++}
++
++assert.deepStrictEqual(found, [], `channel links without the @ (use channelPath() on the client, /@\${encodeURIComponent(username)} on the server):\n${found.join('\n')}`);
++console.log('channel links: all checks passed');