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
Changes
@@@ -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"},@@@ -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:`);@@@ -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 {@@@ -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);nnew file mode 100644@@@ -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 };@@@ -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));nnew file mode 100644@@@ -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');
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
"dev": "NODE_ENV=development 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",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
"grant-admin": "node server/grant-admin.js"
},
},
@@ -1102,6 +1102,8 @@ async function loadUsers() {
@@ -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-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-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 ? `
${currentUser?.is_owner ? `
<button class="btn btn-sm btn-outline" title="Edit email address"
<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>
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) {
@@ -1156,6 +1158,24 @@ async function sendUserReset(userId, username) {
} catch (e) { toast(e.message, 'error'); }
} 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) {
async function toggleBan(userId, username, ban) {
if (ban) {
if (ban) {
const reason = prompt(`Ban ${username}? Enter reason:`);
const reason = prompt(`Ban ${username}? Enter reason:`);
@@ -742,6 +742,20 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -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).
// End someone's sessions on every device and site (a stolen token, a shared computer).
router.post('/users/:id/sign-out', (req, res) => {
router.post('/users/:id/sign-out', (req, res) => {
try {
try {
@@ -120,6 +120,8 @@ router.post('/register', (req, res) => {
@@ -120,6 +120,8 @@ router.post('/register', (req, res) => {
const existing = db.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?)').get(username);
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' });
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)
// Check if username is reserved (has an active verification key)
const reserved = db.isUsernameReserved(username);
const reserved = db.isUsernameReserved(username);
new file mode 100644
new file mode 100644
@@ -0,0 +1,87 @@
@@ -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 };
@@ -384,6 +384,13 @@ const jwt = require('jsonwebtoken');
@@ -384,6 +384,13 @@ const jwt = require('jsonwebtoken');
const requireAuth = require('./auth/session').makeRequireAuth(() => ({ db, publicKey, config }), authRoutes.signToken);
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.
// The moderation audit log for staff (staff.moderation.logs): server/admin/moderation-audit.js.
app.use('/api/v1/staff/moderation-audit', requireAuth, moderationAudit.router());
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.
// 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.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));
app.get('/internal/integrations/github-token', require('./identity/principals').guard('network.integration.github.read', { legacy: false }), require('./integrations/github').internalHandler(db));
new file mode 100644
new file mode 100644
@@ -0,0 +1,58 @@
@@ -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');