openvibers/openvibe.network Commits

Username history (WS-B task 6): admins rename people (admin → Users → Rename, owner-protected, audited); every rename is kept in username_history, the old name stays reserved for 180 days (registration and renames refuse it for anyone else), and GET /api/v1/users/renamed/:name gives the current name so sites redirect /@old → /@new. Admin users also get 'Sign out everywhere'. test/usernames.test.js

OP OpenVibers committed 24 Sept 2026 · 74d46102c4 · 7 files, +189 −1


Changes

ddiff --git a/package.json b/package.json
iindex 2cfd9c34..e7015791 100644
---- a/package.json
++++ b/package.json
@@@ -6,7 +6,7 @@
"scripts": {
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
-- "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/github-integration.test.js && node test/revocation.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js",
++ "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/github-integration.test.js && node test/revocation.test.js && node test/usernames.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
},
ddiff --git a/public/admin.html b/public/admin.html
iindex 6bb4e41e..f2e9030a 100644
---- a/public/admin.html
++++ b/public/admin.html
@@@ -1102,6 +1102,8 @@ async function loadUsers() {
`<button class="btn btn-sm btn-success" onclick="toggleBan('${u.id}','${esc(u.username)}',false)"><i class="fa-solid fa-user-check"></i></button>` :
`<button class="btn btn-sm btn-danger" onclick="toggleBan('${u.id}','${esc(u.username)}',true)"><i class="fa-solid fa-ban"></i></button>`
}
++ <button class="btn btn-sm btn-outline" title="Rename (the old name redirects on Live and stays reserved)" onclick="renameUser('${u.id}','${esc(u.username)}')"><i class="fa-solid fa-signature"></i></button>
++ <button class="btn btn-sm btn-outline" title="Sign out on every device and site" onclick="signOutUser('${u.id}','${esc(u.username)}')"><i class="fa-solid fa-right-from-bracket"></i></button>
${currentUser?.is_owner ? `
<button class="btn btn-sm btn-outline" title="Edit email address"
onclick="editUserEmail('${u.id}','${esc(u.username)}','${esc(u.email || '')}')"><i class="fa-solid fa-envelope"></i></button>
@@@ -1156,6 +1158,24 @@ async function sendUserReset(userId, username) {
} catch (e) { toast(e.message, 'error'); }
}
++async function renameUser(userId, username) {
++ const next = prompt(`New username for ${username}? (3-24 letters, numbers, underscores. /@${username} will redirect to it on Live, and "${username}" stays reserved for them.)`, username);
++ if (next === null || next.trim() === username) return;
++ try {
++ const r = await api(`/api/admin/users/${userId}/username`, { method: 'PUT', body: { username: next.trim() } });
++ toast(`${r.from} is now ${r.to}`, 'success');
++ loadUsers();
++ } catch (e) { toast(e.message, 'error'); }
++}
++
++async function signOutUser(userId, username) {
++ if (!confirm(`Sign ${username} out on every device and every OpenVibe site?`)) return;
++ try {
++ await api(`/api/admin/users/${userId}/sign-out`, { method: 'POST' });
++ toast(`${username} signed out everywhere`, 'success');
++ } catch (e) { toast(e.message, 'error'); }
++}
++
async function toggleBan(userId, username, ban) {
if (ban) {
const reason = prompt(`Ban ${username}? Enter reason:`);
ddiff --git a/server/admin/routes.js b/server/admin/routes.js
iindex bd512414..fa72dac2 100644
---- a/server/admin/routes.js
++++ b/server/admin/routes.js
@@@ -742,6 +742,20 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
}
});
++ // Rename someone (their old name redirects on Live and stays reserved; server/identity/usernames.js).
++ router.put('/users/:id/username', (req, res) => {
++ try {
++ const target = db.prepare('SELECT id, username, role FROM users WHERE id = ?').get(req.params.id);
++ if (!target) return res.status(404).json({ ok: false, error: 'No such user' });
++ if (isOwner(target) && !isOwner(req.user)) return res.status(403).json({ ok: false, error: "Only the owner renames the owner" });
++ const out = require('../identity/usernames').rename(db, target.id, String((req.body && req.body.username) || ''), { actorId: req.user.id });
++ db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'user_rename', JSON.stringify({ targetId: target.id, ...out }));
++ res.json({ ok: true, ...out });
++ } catch (err) {
++ res.status(err.status || 500).json({ ok: false, error: err.message });
++ }
++ });
++
// End someone's sessions on every device and site (a stolen token, a shared computer).
router.post('/users/:id/sign-out', (req, res) => {
try {
ddiff --git a/server/auth/routes.js b/server/auth/routes.js
iindex 20d39435..a12581b7 100644
---- a/server/auth/routes.js
++++ b/server/auth/routes.js
@@@ -120,6 +120,8 @@ router.post('/register', (req, res) => {
const existing = db.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?)').get(username);
if (existing) return res.status(409).json({ error: 'Username already taken' });
++ // A name someone was renamed away from stays theirs for a while (links, mentions, reputation).
++ if (require('../identity/usernames').isReserved(db, username)) return res.status(409).json({ error: 'Username already taken' });
// Check if username is reserved (has an active verification key)
const reserved = db.isUsernameReserved(username);
ddiff --git a/server/identity/usernames.js b/server/identity/usernames.js
nnew file mode 100644
iindex 00000000..2693912a
---- /dev/null
++++ b/server/identity/usernames.js
@@@ -0,0 +1,87 @@
++'use strict';
++/**
++ * Username history (roadmap WS-B task 6, D01).
++ *
++ * Usernames are chosen at registration and, since 2026-09-24, can be changed only by staff (admin →
++ * Users → Rename). Every rename is kept in username_history, so:
++ * - openvibe.live answers /@old with a 301 to /@new for renamed channels (never for a bare /old,
++ * which stays a 404: channel URLs are /@username only);
++ * - GET /api/v1/users/renamed/:name tells any site the current name for an old one;
++ * - an old name stays reserved for RESERVE_DAYS, so nobody can take it over and inherit the
++ * person's links, mentions and reputation. Only the person who held it may take it back.
++ */
++
++const NAME_RE = /^[A-Za-z0-9_]{3,24}$/;
++const RESERVE_DAYS = 180;
++const SYSTEM_NAMES = new Set(['live', 'system', 'openvibelivebot']);
++
++function ensureSchema(db) {
++ db.exec(`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_by INTEGER,
++ changed_at DATETIME DEFAULT CURRENT_TIMESTAMP
++ );
++ CREATE INDEX IF NOT EXISTS idx_username_history_old ON username_history(old_username COLLATE NOCASE);
++ CREATE INDEX IF NOT EXISTS idx_username_history_user ON username_history(user_id);`);
++}
++
++/** Whether `name` is an old username someone other than `exceptUserId` gave up within RESERVE_DAYS. */
++function isReserved(db, name, exceptUserId = null) {
++ ensureSchema(db);
++ const row = db.prepare(`SELECT user_id FROM username_history WHERE old_username = ? COLLATE NOCASE
++ AND changed_at > datetime('now', ?) ORDER BY id DESC LIMIT 1`).get(String(name), `-${RESERVE_DAYS} days`);
++ return Boolean(row && row.user_id !== exceptUserId);
++}
++
++/** Why `name` cannot be used, or null. */
++function problemWith(db, name, userId = null) {
++ if (!NAME_RE.test(name)) return 'Usernames are 3-24 letters, numbers and underscores';
++ if (/^anon/i.test(name)) return 'Usernames cannot start with "anon"';
++ if (SYSTEM_NAMES.has(name.toLowerCase())) return 'That name is reserved by the system';
++ const taken = db.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?)').get(name);
++ if (taken && taken.id !== userId) return 'That username is taken';
++ if (isReserved(db, name, userId)) return `That name belonged to someone else in the last ${RESERVE_DAYS} days`;
++ return null;
++}
++
++/**
++ * Rename a user. A display name that was just the old username (any capitalisation) follows it.
++ * @returns {{ from: string, to: string }}
++ */
++function rename(db, userId, newName, { actorId = null } = {}) {
++ ensureSchema(db);
++ const to = String(newName || '').trim();
++ return db.transaction(() => {
++ const user = db.prepare('SELECT id, username, display_name FROM users WHERE id = ?').get(userId);
++ if (!user) throw Object.assign(new Error('No such user'), { status: 404 });
++ if (to === user.username) throw Object.assign(new Error('That is already the username'), { status: 400 });
++ const problem = problemWith(db, to, user.id);
++ if (problem) throw Object.assign(new Error(problem), { status: 409 });
++ db.prepare('INSERT INTO username_history (user_id, old_username, new_username, changed_by) VALUES (?, ?, ?, ?)').run(user.id, user.username, to, actorId);
++ const followDisplay = !user.display_name || user.display_name.toLowerCase() === user.username.toLowerCase();
++ db.prepare(`UPDATE users SET username = ?${followDisplay ? ', display_name = ?' : ''}, updated_at = CURRENT_TIMESTAMP WHERE id = ?`)
++ .run(...(followDisplay ? [to, to, user.id] : [to, user.id]));
++ return { from: user.username, to };
++ })();
++}
++
++/** The current username for an old one, or null (never for a name that is someone's current name). */
++function renamedTo(db, oldName) {
++ ensureSchema(db);
++ if (!NAME_RE.test(String(oldName || ''))) return null;
++ if (db.prepare('SELECT 1 FROM users WHERE LOWER(username) = LOWER(?)').get(oldName)) return null;
++ const row = db.prepare(`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`).get(oldName);
++ return row ? row.username : null;
++}
++
++/** A user's past usernames, newest first. */
++function historyOf(db, userId) {
++ ensureSchema(db);
++ return db.prepare('SELECT old_username, new_username, changed_at FROM username_history WHERE user_id = ? ORDER BY id DESC').all(userId);
++}
++
++module.exports = { ensureSchema, isReserved, problemWith, rename, renamedTo, historyOf, RESERVE_DAYS, NAME_RE };
ddiff --git a/server/index.js b/server/index.js
iindex 745b5147..e15c9b0d 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -384,6 +384,13 @@ const jwt = require('jsonwebtoken');
const requireAuth = require('./auth/session').makeRequireAuth(() => ({ db, publicKey, config }), authRoutes.signToken);
// The moderation audit log for staff (staff.moderation.logs): server/admin/moderation-audit.js.
app.use('/api/v1/staff/moderation-audit', requireAuth, moderationAudit.router());
++// Username history (WS-B task 6): the current name for an old one, so sites redirect /@old → /@new.
++app.get('/api/v1/users/renamed/:name', rateLimit({ windowMs: 60_000, max: 120 }), (req, res) => {
++ const to = require('./identity/usernames').renamedTo(db, req.params.name);
++ res.set('Cache-Control', 'public, max-age=300');
++ if (!to) return res.status(404).json({ error: 'not_renamed' });
++ res.json({ username: to });
++});
// The GitHub token (admin → Settings → GitHub, or GITHUB_TOKEN): owner-only admin, and Blog's changelog reads it.
app.use('/api/admin/integrations/github', requireAuth, require('./integrations/github').adminRouter(db));
app.get('/internal/integrations/github-token', require('./identity/principals').guard('network.integration.github.read', { legacy: false }), require('./integrations/github').internalHandler(db));
ddiff --git a/test/usernames.test.js b/test/usernames.test.js
nnew file mode 100644
iindex 00000000..a4bc49ac
---- /dev/null
++++ b/test/usernames.test.js
@@@ -0,0 +1,58 @@
++'use strict';
++// Username history (WS-B task 6): staff renames are recorded, an old name redirects to the current one
++// (through several renames), stays reserved for everyone else, and never shadows someone's current name.
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const { initDb } = require('../server/db/database');
++const u = require('../server/identity/usernames');
++
++const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-usernames-'));
++const log = console.log; console.log = () => {};
++const db = initDb(path.join(dir, 'network.db'));
++console.log = log;
++const add = (name, display = null) => db.prepare('INSERT INTO users (username, display_name, password_hash) VALUES (?, ?, ?)').run(name, display, 'x').lastInsertRowid;
++
++try {
++ const ann = add('ann_old', 'Ann_Old');
++ const bob = add('bob', 'Bob the Builder');
++ assert.deepStrictEqual(u.rename(db, ann, 'ann_new', { actorId: bob }), { from: 'ann_old', to: 'ann_new' });
++ let row = db.prepare('SELECT username, display_name FROM users WHERE id = ?').get(ann);
++ assert.deepStrictEqual([row.username, row.display_name], ['ann_new', 'ann_new'], 'a display name that was the username follows it');
++ assert.strictEqual(u.renamedTo(db, 'ANN_OLD'), 'ann_new', 'case-insensitive');
++ assert.strictEqual(u.renamedTo(db, 'ann_new'), null, 'a current name is never "renamed"');
++ assert.strictEqual(u.renamedTo(db, 'nobody_here'), null);
++ assert.strictEqual(u.renamedTo(db, '../etc'), null);
++
++ // Reserved for everyone else, not for the person who held it.
++ assert.strictEqual(u.isReserved(db, 'ann_old'), true);
++ assert.match(u.problemWith(db, 'ann_old', bob), /belonged to someone else/);
++ assert.throws(() => u.rename(db, bob, 'ann_old'), /belonged to someone else/);
++ assert.strictEqual(u.problemWith(db, 'ann_old', ann), null, 'Ann may take her old name back');
++
++ // Several renames: every old name points at the current one.
++ u.rename(db, ann, 'ann_third');
++ assert.strictEqual(u.renamedTo(db, 'ann_old'), 'ann_third');
++ assert.strictEqual(u.renamedTo(db, 'ann_new'), 'ann_third');
++ assert.deepStrictEqual(u.historyOf(db, ann).map(h => [h.old_username, h.new_username]), [['ann_new', 'ann_third'], ['ann_old', 'ann_new']]);
++
++ // A display name that is not the username stays; bad and taken names are refused.
++ u.rename(db, bob, 'bobby');
++ assert.strictEqual(db.prepare('SELECT display_name FROM users WHERE id = ?').get(bob).display_name, 'Bob the Builder');
++ assert.throws(() => u.rename(db, bob, 'ann_third'), /taken/);
++ assert.throws(() => u.rename(db, bob, 'no spaces'), /3-24 letters/);
++ assert.throws(() => u.rename(db, bob, 'anon123'), /anon/);
++ assert.throws(() => u.rename(db, bob, 'System'), /reserved by the system/);
++ assert.throws(() => u.rename(db, 9999, 'whoever'), /No such user/);
++
++ // Wiring: registration refuses reserved names, admins rename (owner-protected), the lookup is public.
++ const auth = fs.readFileSync(path.join(__dirname, '../server/auth/routes.js'), 'utf8');
++ assert.ok(/usernames'\)\.isReserved\(db, username\)/.test(auth));
++ const admin = fs.readFileSync(path.join(__dirname, '../server/admin/routes.js'), 'utf8');
++ assert.ok(/router\.put\('\/users\/:id\/username'/.test(admin) && /Only the owner renames the owner/.test(admin));
++ assert.ok(/app\.get\('\/api\/v1\/users\/renamed\/:name'/.test(fs.readFileSync(path.join(__dirname, '../server/index.js'), 'utf8')));
++} finally {
++ fs.rmSync(dir, { recursive: true, force: true });
++}
++console.log('usernames: all checks passed');