openvibers/openvibe.live Commits
Renamed channels (WS-B task 6): Live follows a Network rename (the signed token's username at sign-in, or GET /api/v1/users/names/:name for a /@name it does not know yet), keeps username_history, and the page fallback answers /@old with a 301 to /@current (sub-path and query kept). A bare /name stays a 404; a name another Live user holds is never taken. test/renamed-channels.test.js
Changes
@@@ -99,6 +99,11 @@ const _ROLE_RANK = { user: 0, streamer: 1, global_mod: 2, admin: 3 };function _syncSsoUserFields(user, decoded) {if (!user || !decoded) return user;++ // Renamed on the Network (staff only; the old name stays theirs there): follow it here, and++ // /@old keeps working as a 301 (server/auth/usernames.js, WS-B task 6).++ if (typeof decoded.username === 'string' && user.username && decoded.username.toLowerCase() !== String(user.username).toLowerCase()) {++ try { if (require('./usernames').syncUsername(user.id, decoded.username)) user = db.getUserById(user.id) || user; } catch { /* keep the old name */ }++ }const updates = [];const params = [];if (decoded.avatar_url && decoded.avatar_url !== user.avatar_url) { updates.push('avatar_url = ?'); params.push(decoded.avatar_url); }nnew file mode 100644@@@ -0,0 +1,103 @@++'use strict';++/**++ * Renamed channels (roadmap WS-B task 6). OpenVibe.Network owns usernames and keeps their history++ * (staff rename people there; the old name stays reserved). Live follows:++ *++ * - a Network token whose username differs from the linked Live user's renames them here too++ * (auth.js _syncSsoUserFields), recorded in username_history;++ * - /@name that Live does not know asks Network (GET /api/v1/users/names/:name): an old name++ * answers 301 → /@current (server/web/page-status.js), and a new name Live has not seen yet is++ * picked up at once, before the person's next sign-in.++ *++ * Only /@name redirects. A bare /name is never a channel URL and stays a 404.++ */++const db = require('../db/database');++const config = require('../config');++++const NAME_RE = /^[A-Za-z0-9_]{3,24}$/;++const LOOKUP_TTL_MS = 60_000;++let ready = false;++const _lookups = new Map(); // lowercased name → { at, value: record|null }++++function ensureSchema() {++ if (ready) return;++ db.run(`CREATE TABLE IF NOT EXISTS username_history (++ id INTEGER PRIMARY KEY AUTOINCREMENT,++ user_id INTEGER NOT NULL,++ old_username TEXT NOT NULL,++ new_username TEXT NOT NULL,++ changed_at DATETIME DEFAULT CURRENT_TIMESTAMP++ )`);++ db.run('CREATE INDEX IF NOT EXISTS idx_username_history_old ON username_history(old_username COLLATE NOCASE)');++ ready = true;++}++++/**++ * Rename a Live user to the Network's name for them, unless only the case differs or another Live++ * user already holds it. Returns true when it changed.++ */++function syncUsername(userId, newName) {++ if (!userId || !NAME_RE.test(String(newName || ''))) return false;++ const user = db.get('SELECT id, username FROM users WHERE id = ?', [userId]);++ if (!user || String(user.username || '').toLowerCase() === newName.toLowerCase()) return false;++ const clash = db.get('SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id != ?', [newName, userId]);++ if (clash) { console.warn(`[Usernames] ${user.username} → ${newName}: another Live user holds that name; not renamed`); return false; }++ ensureSchema();++ db.getDb().transaction(() => {++ db.run('INSERT INTO username_history (user_id, old_username, new_username) VALUES (?, ?, ?)', [user.id, user.username, newName]);++ db.run('UPDATE users SET username = ? WHERE id = ?', [newName, user.id]);++ })();++ console.log(`[Usernames] renamed ${user.username} → ${newName} (Network)`);++ return true;++}++++/** The current Live username for an old one, from Live's own history (null when unknown or reused). */++function localRenamedTo(name) {++ if (!NAME_RE.test(String(name || ''))) return null;++ ensureSchema();++ if (db.get('SELECT 1 AS x FROM users WHERE username = ? COLLATE NOCASE', [name])) return null;++ const row = db.get(`SELECT u.username FROM username_history h JOIN users u ON u.id = h.user_id++ WHERE h.old_username = ? COLLATE NOCASE ORDER BY h.id DESC LIMIT 1`, [name]);++ return row ? row.username : null;++}++++async function networkRecord(name, { fetchImpl = globalThis.fetch, timeoutMs = 1500 } = {}) {++ const key = name.toLowerCase();++ const hit = _lookups.get(key);++ if (hit && Date.now() - hit.at < LOOKUP_TTL_MS) return hit.value;++ let value = null;++ try {++ const base = String(config.openvibeToolsInternalUrl || 'http://127.0.0.1:4000').replace(/\/+$/, '');++ const r = await fetchImpl(`${base}/api/v1/users/names/${encodeURIComponent(name)}`, { signal: AbortSignal.timeout(timeoutMs) });++ if (r.ok) {++ const b = await r.json();++ if (b && NAME_RE.test(String(b.current || '')) && Number.isInteger(b.network_id)) value = b;++ } else if (r.status !== 404) return null; // an outage is not a verdict: do not cache it++ } catch { return null; }++ if (_lookups.size > 5000) _lookups.clear();++ _lookups.set(key, { at: Date.now(), value });++ return value;++}++++/**++ * For a /@name Live does not know: the username to send the visitor to (the current name of a++ * renamed channel, or `name` itself once a new name was picked up), or null for a real 404.++ */++async function resolveUnknownChannel(name, opts = {}) {++ if (!NAME_RE.test(String(name || ''))) return null;++ const local = localRenamedTo(name);++ if (local) return local;++ const rec = await networkRecord(name, opts);++ if (!rec) return null;++ const linked = db.get("SELECT user_id FROM linked_accounts WHERE service = 'network' AND service_user_id = ? ORDER BY id DESC LIMIT 1", [String(rec.network_id)]);++ if (!linked) return null;++ const user = db.get('SELECT id, username FROM users WHERE id = ?', [linked.user_id]);++ if (!user) return null;++ if (String(user.username).toLowerCase() !== rec.current.toLowerCase()) syncUsername(user.id, rec.current);++ const now = db.get('SELECT username FROM users WHERE id = ?', [user.id]);++ return now ? now.username : null;++}++++function _reset() { _lookups.clear(); ready = false; }++++module.exports = { syncUsername, localRenamedTo, resolveUnknownChannel, ensureSchema, _reset, NAME_RE };@@@ -17,7 +17,8 @@* /vods /clips /pastes /updates /documentation exact path (the first three are /content filtered)* /settings /admin /themes exact path (the client redirects them)* /dashboard/… /broadcast/… /chat/… /arena/… any depth: these pages route sub-paths themselves-- * /@user /@user/<slot> a user that exists++ * /@user /@user/<slot> a user that exists; a renamed one's old name++ * answers 301 → /@current (never a bare /name)* /vod/:id /clip/:id a Media item this visitor may see — a private* one is missing to everyone but its owners and* staff (server/media-proxy/access.js)@@@ -184,8 +185,22 @@ function spaFallback(sendShell) {if (req.url.startsWith('/api/') || req.url.startsWith('/ws/')) {return res.status(404).json({ error: 'Not found' });}-- const status = await statusFor(req);++ let status = await statusFor(req);if (res.headersSent) return;++ if (status === NOT_FOUND) {++ // A renamed channel (Network username history): /@old → 301 /@current, and a new name Live++ // has not seen yet is picked up now. Only /@ paths: a bare /name is never a channel URL.++ const m = /^\/@+([A-Za-z0-9_]{3,24})(\/[^/]+)?\/?$/.exec(req.path);++ if (m) {++ const target = await require('../auth/usernames').resolveUnknownChannel(m[1]).catch(() => null);++ if (res.headersSent) return;++ if (target && target.toLowerCase() !== m[1].toLowerCase()) {++ const q = req.originalUrl.indexOf('?');++ return res.redirect(301, `/@${target}${m[2] || ''}${q >= 0 ? req.originalUrl.slice(q) : ''}`);++ }++ if (target) status = OK;++ }++ }try {res.status(status);if (!sendShell(res, req.path)) res.status(503).type('text/plain').send('Site shell unavailable');nnew file mode 100644@@@ -0,0 +1,71 @@++'use strict';++// Renamed channels (WS-B task 6): Live follows a Network rename (from the signed token, or from the++// Network names lookup for a name it has not seen), keeps a history, and /@old answers 301 → /@current++// (sub-path and query kept); a bare /old stays a 404, unknown names stay 404, clashes never rename.++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-live-rename-'));++process.env.DB_PATH = path.join(tmp, 'live.db');++process.env.NODE_ENV = 'test';++const quiet = console.log;++console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };++console.warn = () => {};++++(async () => {++ // Stub Network: GET /api/v1/users/names/:name++ const names = { carl_2: { current: 'carl_2', network_id: 88, renamed: false, previous_names: ['carl'] } };++ const net = http.createServer((req, res) => {++ const m = /^\/api\/v1\/users\/names\/([^/?]+)/.exec(req.url);++ const rec = m && names[decodeURIComponent(m[1]).toLowerCase()];++ res.writeHead(rec ? 200 : 404, { 'content-type': 'application/json' });++ res.end(JSON.stringify(rec || { error: 'not_found' }));++ });++ await new Promise((r) => net.listen(0, '127.0.0.1', r));++ process.env.OV_NETWORK_INTERNAL_URL = `http://127.0.0.1:${net.address().port}`;++++ const express = require('express');++ const db = require('../server/db/database');++ db.initDb();++ const usernames = require('../server/auth/usernames');++ const auth = require('../server/auth/auth');++ const pageStatus = require('../server/web/page-status');++ const add = (name, networkId) => {++ const id = db.getDb().prepare("INSERT INTO users (username, password_hash, display_name) VALUES (?, 'x', ?)").run(name, name).lastInsertRowid;++ if (networkId) db.getDb().prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, service_username) VALUES (?, 'network', ?, ?)").run(id, String(networkId), name);++ return id;++ };++ const ann = add('ann_old', 57), bob = add('bob', 58), carl = add('carl', 88);++++ const app = express();++ app.get('*', pageStatus.spaFallback((res) => { res.type('html').send('<shell>'); return true; }));++ const srv = http.createServer(app);++ await new Promise((r) => srv.listen(0, '127.0.0.1', r));++ const get = (p) => fetch(`http://127.0.0.1:${srv.address().port}${p}`, { redirect: 'manual' }).then((r) => ({ status: r.status, location: r.headers.get('location') }));++ try {++ // Follow a rename carried by the signed token (resolveNetworkUser runs the SSO field sync).++ const u = auth.resolveNetworkUser({ sub: 57, id: 57, username: 'ann_new', role: 'user' });++ assert.strictEqual(u && u.username, 'ann_new');++ assert.strictEqual(usernames.localRenamedTo('ANN_OLD'), 'ann_new');++ assert.strictEqual(usernames.syncUsername(bob, 'ann_new'), false, 'never onto someone else\'s name');++ assert.strictEqual(usernames.syncUsername(ann, 'ANN_NEW'), false, 'case-only is not a rename');++++ assert.deepStrictEqual(await get('/@ann_new'), { status: 200, location: null });++ assert.deepStrictEqual(await get('/@ann_old'), { status: 301, location: '/@ann_new' });++ assert.deepStrictEqual(await get('/@ann_old/slot2?ref=hat'), { status: 301, location: '/@ann_new/slot2?ref=hat' });++ assert.strictEqual((await get('/ann_old')).status, 404, 'a bare /name is never a channel URL');++ assert.strictEqual((await get('/@nobody_here')).status, 404);++++ // A new name Live has not seen yet: picked up from the Network, and the old one redirects.++ assert.strictEqual((await get('/@carl_2')).status, 200);++ assert.strictEqual(db.getUserById(carl).username, 'carl_2');++ assert.deepStrictEqual(await get('/@carl'), { status: 301, location: '/@carl_2' });++ } finally {++ srv.close(); net.close();++ fs.rmSync(tmp, { recursive: true, force: true });++ }++ quiet('renamed channels: all checks passed');++})().catch((e) => { console.error = quiet; quiet(e); process.exit(1); });
@@ -99,6 +99,11 @@ const _ROLE_RANK = { user: 0, streamer: 1, global_mod: 2, admin: 3 };
@@ -99,6 +99,11 @@ const _ROLE_RANK = { user: 0, streamer: 1, global_mod: 2, admin: 3 };
function _syncSsoUserFields(user, decoded) {
function _syncSsoUserFields(user, decoded) {
if (!user || !decoded) return user;
if (!user || !decoded) return user;
+ // Renamed on the Network (staff only; the old name stays theirs there): follow it here, and
+ // /@old keeps working as a 301 (server/auth/usernames.js, WS-B task 6).
+ if (typeof decoded.username === 'string' && user.username && decoded.username.toLowerCase() !== String(user.username).toLowerCase()) {
+ try { if (require('./usernames').syncUsername(user.id, decoded.username)) user = db.getUserById(user.id) || user; } catch { /* keep the old name */ }
+ }
const updates = [];
const updates = [];
const params = [];
const params = [];
if (decoded.avatar_url && decoded.avatar_url !== user.avatar_url) { updates.push('avatar_url = ?'); params.push(decoded.avatar_url); }
if (decoded.avatar_url && decoded.avatar_url !== user.avatar_url) { updates.push('avatar_url = ?'); params.push(decoded.avatar_url); }
new file mode 100644
new file mode 100644
@@ -0,0 +1,103 @@
@@ -0,0 +1,103 @@
+'use strict';
+/**
+ * Renamed channels (roadmap WS-B task 6). OpenVibe.Network owns usernames and keeps their history
+ * (staff rename people there; the old name stays reserved). Live follows:
+ *
+ * - a Network token whose username differs from the linked Live user's renames them here too
+ * (auth.js _syncSsoUserFields), recorded in username_history;
+ * - /@name that Live does not know asks Network (GET /api/v1/users/names/:name): an old name
+ * answers 301 → /@current (server/web/page-status.js), and a new name Live has not seen yet is
+ * picked up at once, before the person's next sign-in.
+ *
+ * Only /@name redirects. A bare /name is never a channel URL and stays a 404.
+ */
+const db = require('../db/database');
+const config = require('../config');
+
+const NAME_RE = /^[A-Za-z0-9_]{3,24}$/;
+const LOOKUP_TTL_MS = 60_000;
+let ready = false;
+const _lookups = new Map(); // lowercased name → { at, value: record|null }
+
+function ensureSchema() {
+ if (ready) return;
+ db.run(`CREATE TABLE IF NOT EXISTS username_history (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL,
+ old_username TEXT NOT NULL,
+ new_username TEXT NOT NULL,
+ changed_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ )`);
+ db.run('CREATE INDEX IF NOT EXISTS idx_username_history_old ON username_history(old_username COLLATE NOCASE)');
+ ready = true;
+}
+
+/**
+ * Rename a Live user to the Network's name for them, unless only the case differs or another Live
+ * user already holds it. Returns true when it changed.
+ */
+function syncUsername(userId, newName) {
+ if (!userId || !NAME_RE.test(String(newName || ''))) return false;
+ const user = db.get('SELECT id, username FROM users WHERE id = ?', [userId]);
+ if (!user || String(user.username || '').toLowerCase() === newName.toLowerCase()) return false;
+ const clash = db.get('SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id != ?', [newName, userId]);
+ if (clash) { console.warn(`[Usernames] ${user.username} → ${newName}: another Live user holds that name; not renamed`); return false; }
+ ensureSchema();
+ db.getDb().transaction(() => {
+ db.run('INSERT INTO username_history (user_id, old_username, new_username) VALUES (?, ?, ?)', [user.id, user.username, newName]);
+ db.run('UPDATE users SET username = ? WHERE id = ?', [newName, user.id]);
+ })();
+ console.log(`[Usernames] renamed ${user.username} → ${newName} (Network)`);
+ return true;
+}
+
+/** The current Live username for an old one, from Live's own history (null when unknown or reused). */
+function localRenamedTo(name) {
+ if (!NAME_RE.test(String(name || ''))) return null;
+ ensureSchema();
+ if (db.get('SELECT 1 AS x FROM users WHERE username = ? COLLATE NOCASE', [name])) return null;
+ const row = db.get(`SELECT u.username FROM username_history h JOIN users u ON u.id = h.user_id
+ WHERE h.old_username = ? COLLATE NOCASE ORDER BY h.id DESC LIMIT 1`, [name]);
+ return row ? row.username : null;
+}
+
+async function networkRecord(name, { fetchImpl = globalThis.fetch, timeoutMs = 1500 } = {}) {
+ const key = name.toLowerCase();
+ const hit = _lookups.get(key);
+ if (hit && Date.now() - hit.at < LOOKUP_TTL_MS) return hit.value;
+ let value = null;
+ try {
+ const base = String(config.openvibeToolsInternalUrl || 'http://127.0.0.1:4000').replace(/\/+$/, '');
+ const r = await fetchImpl(`${base}/api/v1/users/names/${encodeURIComponent(name)}`, { signal: AbortSignal.timeout(timeoutMs) });
+ if (r.ok) {
+ const b = await r.json();
+ if (b && NAME_RE.test(String(b.current || '')) && Number.isInteger(b.network_id)) value = b;
+ } else if (r.status !== 404) return null; // an outage is not a verdict: do not cache it
+ } catch { return null; }
+ if (_lookups.size > 5000) _lookups.clear();
+ _lookups.set(key, { at: Date.now(), value });
+ return value;
+}
+
+/**
+ * For a /@name Live does not know: the username to send the visitor to (the current name of a
+ * renamed channel, or `name` itself once a new name was picked up), or null for a real 404.
+ */
+async function resolveUnknownChannel(name, opts = {}) {
+ if (!NAME_RE.test(String(name || ''))) return null;
+ const local = localRenamedTo(name);
+ if (local) return local;
+ const rec = await networkRecord(name, opts);
+ if (!rec) return null;
+ const linked = db.get("SELECT user_id FROM linked_accounts WHERE service = 'network' AND service_user_id = ? ORDER BY id DESC LIMIT 1", [String(rec.network_id)]);
+ if (!linked) return null;
+ const user = db.get('SELECT id, username FROM users WHERE id = ?', [linked.user_id]);
+ if (!user) return null;
+ if (String(user.username).toLowerCase() !== rec.current.toLowerCase()) syncUsername(user.id, rec.current);
+ const now = db.get('SELECT username FROM users WHERE id = ?', [user.id]);
+ return now ? now.username : null;
+}
+
+function _reset() { _lookups.clear(); ready = false; }
+
+module.exports = { syncUsername, localRenamedTo, resolveUnknownChannel, ensureSchema, _reset, NAME_RE };
@@ -17,7 +17,8 @@
@@ -17,7 +17,8 @@
* /vods /clips /pastes /updates /documentation exact path (the first three are /content filtered)
* /vods /clips /pastes /updates /documentation exact path (the first three are /content filtered)
* /settings /admin /themes exact path (the client redirects them)
* /settings /admin /themes exact path (the client redirects them)
* /dashboard/… /broadcast/… /chat/… /arena/… any depth: these pages route sub-paths themselves
* /dashboard/… /broadcast/… /chat/… /arena/… any depth: these pages route sub-paths themselves
- * /@user /@user/<slot> a user that exists
+ * /@user /@user/<slot> a user that exists; a renamed one's old name
+ * answers 301 → /@current (never a bare /name)
* /vod/:id /clip/:id a Media item this visitor may see — a private
* /vod/:id /clip/:id a Media item this visitor may see — a private
* one is missing to everyone but its owners and
* one is missing to everyone but its owners and
* staff (server/media-proxy/access.js)
* staff (server/media-proxy/access.js)
@@ -184,8 +185,22 @@ function spaFallback(sendShell) {
@@ -184,8 +185,22 @@ function spaFallback(sendShell) {
if (req.url.startsWith('/api/') || req.url.startsWith('/ws/')) {
if (req.url.startsWith('/api/') || req.url.startsWith('/ws/')) {
return res.status(404).json({ error: 'Not found' });
return res.status(404).json({ error: 'Not found' });
}
}
- const status = await statusFor(req);
+ let status = await statusFor(req);
if (res.headersSent) return;
if (res.headersSent) return;
+ if (status === NOT_FOUND) {
+ // A renamed channel (Network username history): /@old → 301 /@current, and a new name Live
+ // has not seen yet is picked up now. Only /@ paths: a bare /name is never a channel URL.
+ const m = /^\/@+([A-Za-z0-9_]{3,24})(\/[^/]+)?\/?$/.exec(req.path);
+ if (m) {
+ const target = await require('../auth/usernames').resolveUnknownChannel(m[1]).catch(() => null);
+ if (res.headersSent) return;
+ if (target && target.toLowerCase() !== m[1].toLowerCase()) {
+ const q = req.originalUrl.indexOf('?');
+ return res.redirect(301, `/@${target}${m[2] || ''}${q >= 0 ? req.originalUrl.slice(q) : ''}`);
+ }
+ if (target) status = OK;
+ }
+ }
try {
try {
res.status(status);
res.status(status);
if (!sendShell(res, req.path)) res.status(503).type('text/plain').send('Site shell unavailable');
if (!sendShell(res, req.path)) res.status(503).type('text/plain').send('Site shell unavailable');
new file mode 100644
new file mode 100644
@@ -0,0 +1,71 @@
@@ -0,0 +1,71 @@
+'use strict';
+// Renamed channels (WS-B task 6): Live follows a Network rename (from the signed token, or from the
+// Network names lookup for a name it has not seen), keeps a history, and /@old answers 301 → /@current
+// (sub-path and query kept); a bare /old stays a 404, unknown names stay 404, clashes never rename.
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+
+const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-live-rename-'));
+process.env.DB_PATH = path.join(tmp, 'live.db');
+process.env.NODE_ENV = 'test';
+const quiet = console.log;
+console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
+console.warn = () => {};
+
+(async () => {
+ // Stub Network: GET /api/v1/users/names/:name
+ const names = { carl_2: { current: 'carl_2', network_id: 88, renamed: false, previous_names: ['carl'] } };
+ const net = http.createServer((req, res) => {
+ const m = /^\/api\/v1\/users\/names\/([^/?]+)/.exec(req.url);
+ const rec = m && names[decodeURIComponent(m[1]).toLowerCase()];
+ res.writeHead(rec ? 200 : 404, { 'content-type': 'application/json' });
+ res.end(JSON.stringify(rec || { error: 'not_found' }));
+ });
+ await new Promise((r) => net.listen(0, '127.0.0.1', r));
+ process.env.OV_NETWORK_INTERNAL_URL = `http://127.0.0.1:${net.address().port}`;
+
+ const express = require('express');
+ const db = require('../server/db/database');
+ db.initDb();
+ const usernames = require('../server/auth/usernames');
+ const auth = require('../server/auth/auth');
+ const pageStatus = require('../server/web/page-status');
+ const add = (name, networkId) => {
+ const id = db.getDb().prepare("INSERT INTO users (username, password_hash, display_name) VALUES (?, 'x', ?)").run(name, name).lastInsertRowid;
+ if (networkId) db.getDb().prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, service_username) VALUES (?, 'network', ?, ?)").run(id, String(networkId), name);
+ return id;
+ };
+ const ann = add('ann_old', 57), bob = add('bob', 58), carl = add('carl', 88);
+
+ const app = express();
+ app.get('*', pageStatus.spaFallback((res) => { res.type('html').send('<shell>'); return true; }));
+ const srv = http.createServer(app);
+ await new Promise((r) => srv.listen(0, '127.0.0.1', r));
+ const get = (p) => fetch(`http://127.0.0.1:${srv.address().port}${p}`, { redirect: 'manual' }).then((r) => ({ status: r.status, location: r.headers.get('location') }));
+ try {
+ // Follow a rename carried by the signed token (resolveNetworkUser runs the SSO field sync).
+ const u = auth.resolveNetworkUser({ sub: 57, id: 57, username: 'ann_new', role: 'user' });
+ assert.strictEqual(u && u.username, 'ann_new');
+ assert.strictEqual(usernames.localRenamedTo('ANN_OLD'), 'ann_new');
+ assert.strictEqual(usernames.syncUsername(bob, 'ann_new'), false, 'never onto someone else\'s name');
+ assert.strictEqual(usernames.syncUsername(ann, 'ANN_NEW'), false, 'case-only is not a rename');
+
+ assert.deepStrictEqual(await get('/@ann_new'), { status: 200, location: null });
+ assert.deepStrictEqual(await get('/@ann_old'), { status: 301, location: '/@ann_new' });
+ assert.deepStrictEqual(await get('/@ann_old/slot2?ref=hat'), { status: 301, location: '/@ann_new/slot2?ref=hat' });
+ assert.strictEqual((await get('/ann_old')).status, 404, 'a bare /name is never a channel URL');
+ assert.strictEqual((await get('/@nobody_here')).status, 404);
+
+ // A new name Live has not seen yet: picked up from the Network, and the old one redirects.
+ assert.strictEqual((await get('/@carl_2')).status, 200);
+ assert.strictEqual(db.getUserById(carl).username, 'carl_2');
+ assert.deepStrictEqual(await get('/@carl'), { status: 301, location: '/@carl_2' });
+ } finally {
+ srv.close(); net.close();
+ fs.rmSync(tmp, { recursive: true, force: true });
+ }
+ quiet('renamed channels: all checks passed');
+})().catch((e) => { console.error = quiet; quiet(e); process.exit(1); });