database admin yay

This commit is contained in:
legop3
2026-06-28 20:17:58 -04:00
parent 5fca14bef5
commit 78fc891cb9
16 changed files with 851 additions and 21 deletions
@@ -4,7 +4,7 @@
const { app } = require('../../globals/http');
const { renderIndexHtml, renderOgImage } = require('../embedService');
app.get(['/', '/spectate', '/mini', '/display', '/scanner'], async (req, res) => {
app.get(['/', '/spectate', '/mini', '/display', '/scanner', '/database'], async (req, res) => {
try {
const html = await renderIndexHtml(req);
res.type('html').send(html);
@@ -0,0 +1,119 @@
// Identity Admin Service
// Purpose: Exposes lockdown-admin socket operations for inspecting and editing the central identity database.
// Scope: Owns transport-level authorization and delegates every database mutation to identityService.
const io = require('../../globals/io');
const logger = require('../../globals/logger').child('identityAdminService');
const { getRole } = require('../roleService');
const {
listUsersForAdmin,
getUserForAdmin,
addUserSignal,
removeUserSignal,
setVerified,
setDeterrence,
setFeatureState,
deleteFeatureState,
} = require('../identityService');
const MAX_FEATURE_STATE_JSON_BYTES = 64 * 1024;
function isLockdownAdminSocket(socket) {
const role = getRole(socket);
return role === 'lockdown' || role === 'lockdown-admin';
}
function requireLockdownAdmin(socket) {
if (!isLockdownAdminSocket(socket)) {
throw new Error('Lockdown admin required.');
}
}
function normalizeFeaturePayload(namespace, value) {
const ns = String(namespace || '').trim();
if (!ns) throw new Error('Feature namespace required.');
if (!/^[a-zA-Z0-9_.:-]{1,80}$/.test(ns)) {
throw new Error('Feature namespace contains invalid characters.');
}
/*
Feature state is intentionally JSON-shaped. Strings are not accepted as the
stored value because they make the admin editor ambiguous: a textarea JSON
string should represent a real JSON object/array, not an escaped blob.
*/
if (!value || typeof value !== 'object') {
throw new Error('Feature state must be a JSON object or array.');
}
const encoded = JSON.stringify(value);
if (Buffer.byteLength(encoded, 'utf8') > MAX_FEATURE_STATE_JSON_BYTES) {
throw new Error('Feature state is too large.');
}
return { namespace: ns, value };
}
function ackHandler(socket, eventName, handler) {
socket.on(eventName, (payload = {}, cb = () => {}) => {
try {
requireLockdownAdmin(socket);
cb({ success: true, ...handler(payload || {}) });
} catch (err) {
logger.warn('Identity admin request failed', {
eventName,
socketId: socket.id,
role: getRole(socket),
error: err.message,
});
cb({ error: err.message });
}
});
}
io.on('connection', (socket) => {
ackHandler(socket, 'identityAdmin:listUsers', () => ({
users: listUsersForAdmin(),
}));
ackHandler(socket, 'identityAdmin:getUser', ({ userId }) => {
const user = getUserForAdmin(userId);
if (!user) throw new Error('User not found.');
return { user };
});
ackHandler(socket, 'identityAdmin:addSignal', ({ userId, type, value }) => ({
user: addUserSignal(userId, type, value),
}));
ackHandler(socket, 'identityAdmin:removeSignal', ({ userId, type, value }) => ({
user: removeUserSignal(userId, type, value),
}));
ackHandler(socket, 'identityAdmin:setVerified', ({ userId, enabled }) => ({
user: getUserForAdmin(setVerified(userId, {
enabled: Boolean(enabled),
actor: socket?.data?.user?.username || socket.id,
at: Date.now(),
}).id),
}));
ackHandler(socket, 'identityAdmin:setDeterrence', ({ userId, enabled, reason }) => ({
user: getUserForAdmin(setDeterrence(userId, {
enabled: Boolean(enabled),
reason: String(reason || '').trim() || null,
actor: socket?.data?.user?.username || socket.id,
at: Date.now(),
}).id),
}));
ackHandler(socket, 'identityAdmin:updateFeatureState', ({ userId, namespace, value }) => {
const normalized = normalizeFeaturePayload(namespace, value);
setFeatureState(userId, normalized.namespace, normalized.value);
return { user: getUserForAdmin(userId) };
});
ackHandler(socket, 'identityAdmin:deleteFeatureState', ({ userId, namespace }) => ({
user: deleteFeatureState(userId, namespace),
}));
});
module.exports = {
isLockdownAdminSocket,
};
@@ -473,6 +473,106 @@ function getUserById(userId, { conn = getDb(), includeFeatures = true } = {}) {
};
}
function listUsersForAdmin() {
const conn = getDb();
return conn.prepare('select id from users order by coalesce(last_seen_at, updated_at, created_at) desc').all()
.map((row) => getUserById(row.id, { conn, includeFeatures: true }))
.filter(Boolean)
.map((user) => ({
...user,
featureNamespaces: Object.keys(user.features || {}).sort(),
}));
}
function getUserForAdmin(userId) {
const user = getUserById(userId, { includeFeatures: true });
return user ? { ...user, featureNamespaces: Object.keys(user.features || {}).sort() } : null;
}
function normalizeAdminSignal(type, value) {
const normalizedType = String(type || '').trim();
if (normalizedType === 'cookieUserId') {
const cookieUserId = normalizeCookieUserId(value);
if (!isValidCookieUserId(cookieUserId)) throw new Error('Invalid cookie identity key.');
return { type: normalizedType, value: cookieUserId };
}
if (normalizedType === 'fingerprintId') {
const fingerprintId = normalizeFingerprintId(value);
if (!isValidFingerprintId(fingerprintId)) throw new Error('Invalid fingerprint id.');
return { type: normalizedType, value: fingerprintId };
}
if (normalizedType === 'nickname') {
const nickname = sanitizeNickname(value);
if (!nickname) throw new Error('Nickname required.');
return { type: normalizedType, value: nickname };
}
if (normalizedType === 'knownIp') {
const ip = normalizeIp(value);
if (!ip) throw new Error('Valid IP required.');
return { type: normalizedType, value: ip };
}
throw new Error('Unknown identity signal type.');
}
function addUserSignal(userId, type, value) {
const id = String(userId || '').trim();
const user = getUserById(id);
if (!user) throw new Error('User not found.');
const signal = normalizeAdminSignal(type, value);
/*
Adding a strong signal is allowed to merge users. If the new cookie key or
fingerprint already belongs to another user, the same global equality rule
applies here and the two records converge under the selected user id.
*/
const conn = getDb();
const nextUser = conn.transaction(() => {
const matchedUserId =
signal.type === 'cookieUserId'
? findUserIdByCookie(conn, signal.value)
: signal.type === 'fingerprintId'
? findUserIdByFingerprint(conn, signal.value)
: null;
const targetUserId = matchedUserId && matchedUserId !== id ? mergeUsers(conn, id, matchedUserId) : id;
const identity = {
cookieUserId: signal.type === 'cookieUserId' ? signal.value : '',
fingerprintId: signal.type === 'fingerprintId' ? signal.value : '',
nickname: signal.type === 'nickname' ? signal.value : '',
ip: signal.type === 'knownIp' ? signal.value : '',
};
return attachIdentitySignals(targetUserId, identity, { conn, ts: nowMs() });
})();
identityEvents.emit('change', { reason: 'admin_signal_add', userId: nextUser.id, signalType: signal.type });
return getUserForAdmin(nextUser.id);
}
function removeUserSignal(userId, type, value) {
const id = String(userId || '').trim();
if (!getUserById(id)) throw new Error('User not found.');
const signal = normalizeAdminSignal(type, value);
const conn = getDb();
/*
Removing a signal only detaches that one identifier. The canonical user row
remains because verification, deterrence, feature state, and legacy imports
may still refer to it even if all strong signals are removed.
*/
if (signal.type === 'cookieUserId') {
conn.prepare('delete from user_cookie_ids where user_id = ? and cookie_user_id = ?').run(id, signal.value);
} else if (signal.type === 'fingerprintId') {
conn.prepare('delete from user_fingerprint_ids where user_id = ? and fingerprint_id = ?').run(id, signal.value);
} else if (signal.type === 'nickname') {
conn.prepare('delete from user_nicknames where user_id = ? and nickname = ?').run(id, signal.value);
} else if (signal.type === 'knownIp') {
conn.prepare('delete from user_known_ips where user_id = ? and ip = ?').run(id, signal.value);
}
conn.prepare('update users set updated_at = ? where id = ?').run(nowMs(), id);
identityEvents.emit('change', { reason: 'admin_signal_remove', userId: id, signalType: signal.type });
return getUserForAdmin(id);
}
function getUserForSocket(socket) {
if (!socket?.data?.userId) return null;
return getUserById(socket.data.userId);
@@ -516,6 +616,15 @@ function setFeatureState(userId, namespace, nextState) {
return nextState || {};
}
function deleteFeatureState(userId, namespace) {
const id = String(userId || '').trim();
const ns = String(namespace || '').trim();
if (!id || !ns) throw new Error('userId and namespace required');
getDb().prepare('delete from user_feature_state where user_id = ? and namespace = ?').run(id, ns);
identityEvents.emit('change', { reason: 'feature_state_delete', userId: id, namespace: ns });
return getUserForAdmin(id);
}
function updateFeatureState(userId, namespace, updater, defaults = {}) {
const current = getFeatureState(userId, namespace, defaults);
const next = typeof updater === 'function' ? updater(current) : updater;
@@ -846,11 +955,16 @@ module.exports = {
resolveUserIdForIdentity,
attachIdentitySignals,
getUserById,
listUsersForAdmin,
getUserForAdmin,
addUserSignal,
removeUserSignal,
getUserForSocket,
getUserIdForSocket,
getIdentitySummary,
getFeatureState,
setFeatureState,
deleteFeatureState,
updateFeatureState,
listFeatureStates,
setVerified,
@@ -9,6 +9,7 @@ const { publishEvent } = require('../eventBus');
const { getNickname, setNickname } = require('../nicknameService');
const { getRole, roleEvents } = require('../roleService');
const {
identityEvents,
getDb,
identifySocket: identifyCanonicalSocket,
getUserById,
@@ -513,6 +514,12 @@ roleEvents.on('change', ({ socket }) => {
}
});
identityEvents.on('change', ({ userId, reason } = {}) => {
if (!userId) return;
refreshSocketsForUser(userId);
emitChange('identity_change', { userId, reason });
});
setInterval(() => {
const now = Date.now();
io.sockets.sockets.forEach((socket) => {