mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
1132 lines
44 KiB
JavaScript
1132 lines
44 KiB
JavaScript
// Identity Service
|
|
// Purpose: Owns canonical user identity, strong identity signals, and per-user feature state.
|
|
// Scope: Keeps all user matching and identity persistence behind one API so other services never
|
|
// need to know whether a user was recognized by portable key, fingerprint, or a future signal.
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
const EventEmitter = require('events');
|
|
const Database = require('better-sqlite3');
|
|
const { getSocketIp, normalizeIp } = require('../../helpers/ipResolver');
|
|
const { resolveDataPath } = require('../../helpers/dataPaths');
|
|
const logger = require('../../globals/logger').child('identityService');
|
|
const { listRegisteredPermissions, requireRegisteredPermission } = require('./permissions');
|
|
|
|
const COOKIE_USER_ID_RE = /^cu_[a-f0-9]{32}$/;
|
|
const FINGERPRINT_ID_RE = /^tm_[a-z0-9_-]{8,256}$/;
|
|
const USER_ID_RE = /^usr_[a-f0-9]{32}$/;
|
|
const DB_PATH = resolveDataPath('identity.sqlite');
|
|
const LEGACY_VERIFICATION_PATH = resolveDataPath('verified-users.json');
|
|
const LEGACY_BARCODE_PATH = resolveDataPath('barcode-games.json');
|
|
const STORE_VERSION = 4;
|
|
const identityEvents = new EventEmitter();
|
|
|
|
let db = null;
|
|
let dbFileExistedAtOpen = false;
|
|
|
|
function nowMs() {
|
|
return Date.now();
|
|
}
|
|
|
|
function sanitizeNickname(raw) {
|
|
if (typeof raw !== 'string') return '';
|
|
const trimmed = raw.trim();
|
|
if (!trimmed) return '';
|
|
return trimmed.replace(/\*/g, 'nope').slice(0, 32);
|
|
}
|
|
|
|
function normalizeCookieUserId(value) {
|
|
const raw = typeof value === 'string' ? value.trim() : '';
|
|
if (!raw) return '';
|
|
return raw.toLowerCase();
|
|
}
|
|
|
|
function isValidCookieUserId(value) {
|
|
return COOKIE_USER_ID_RE.test(normalizeCookieUserId(value));
|
|
}
|
|
|
|
function generateCookieUserId() {
|
|
return `cu_${crypto.randomBytes(16).toString('hex')}`;
|
|
}
|
|
|
|
function normalizeFingerprintId(value) {
|
|
const raw = typeof value === 'string' ? value.trim() : '';
|
|
if (!raw) return '';
|
|
return raw.toLowerCase();
|
|
}
|
|
|
|
function isValidFingerprintId(value) {
|
|
return FINGERPRINT_ID_RE.test(normalizeFingerprintId(value));
|
|
}
|
|
|
|
function generateUserId() {
|
|
return `usr_${crypto.randomBytes(16).toString('hex')}`;
|
|
}
|
|
|
|
function isValidUserId(value) {
|
|
return USER_ID_RE.test(String(value || '').trim());
|
|
}
|
|
|
|
function getKnownIp(socket) {
|
|
return normalizeIp(getSocketIp(socket));
|
|
}
|
|
|
|
function readJsonFile(filePath, fallback) {
|
|
try {
|
|
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
} catch (err) {
|
|
if (err.code !== 'ENOENT') {
|
|
logger.warn('Failed to read legacy JSON during identity import', { path: filePath, error: err.message });
|
|
}
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
function encodeJson(value) {
|
|
return JSON.stringify(value ?? null);
|
|
}
|
|
|
|
function decodeJson(raw, fallback = null) {
|
|
if (typeof raw !== 'string' || !raw) return fallback;
|
|
try {
|
|
return JSON.parse(raw);
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
function getDb() {
|
|
if (db) return db;
|
|
|
|
dbFileExistedAtOpen = fs.existsSync(DB_PATH);
|
|
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
|
|
db = new Database(DB_PATH);
|
|
db.pragma('journal_mode = WAL');
|
|
db.pragma('foreign_keys = ON');
|
|
ensureSchema(db);
|
|
|
|
/*
|
|
The legacy JSON files are intentionally read only when the SQLite database
|
|
is first created. After that point this service treats identity.sqlite as
|
|
the only source of truth, which prevents old files from silently overriding
|
|
or re-importing live identity changes.
|
|
*/
|
|
if (!dbFileExistedAtOpen) {
|
|
migrateLegacyStores(db);
|
|
}
|
|
|
|
return db;
|
|
}
|
|
|
|
function ensureSchema(conn) {
|
|
conn.exec(`
|
|
create table if not exists users (
|
|
id text primary key,
|
|
created_at integer not null,
|
|
updated_at integer not null,
|
|
last_seen_at integer
|
|
);
|
|
|
|
create table if not exists user_cookie_ids (
|
|
cookie_user_id text primary key,
|
|
user_id text not null references users(id) on delete cascade,
|
|
created_at integer not null,
|
|
last_seen_at integer
|
|
);
|
|
create index if not exists idx_user_cookie_ids_user_id on user_cookie_ids(user_id);
|
|
|
|
create table if not exists user_fingerprint_ids (
|
|
fingerprint_id text primary key,
|
|
user_id text not null references users(id) on delete cascade,
|
|
created_at integer not null,
|
|
last_seen_at integer
|
|
);
|
|
create index if not exists idx_user_fingerprint_ids_user_id on user_fingerprint_ids(user_id);
|
|
|
|
create table if not exists user_nicknames (
|
|
user_id text not null references users(id) on delete cascade,
|
|
nickname text not null,
|
|
first_seen_at integer not null,
|
|
last_seen_at integer not null,
|
|
primary key (user_id, nickname)
|
|
);
|
|
|
|
create table if not exists user_known_ips (
|
|
user_id text not null references users(id) on delete cascade,
|
|
ip text not null,
|
|
first_seen_at integer not null,
|
|
last_seen_at integer not null,
|
|
primary key (user_id, ip)
|
|
);
|
|
|
|
create table if not exists user_status (
|
|
user_id text primary key references users(id) on delete cascade,
|
|
verified_enabled integer not null default 0,
|
|
verified_at integer,
|
|
verified_by text,
|
|
deterrence_enabled integer not null default 0,
|
|
deterrence_reason text,
|
|
deterrence_at integer,
|
|
deterrence_by text,
|
|
muted_enabled integer not null default 0,
|
|
muted_at integer,
|
|
muted_by text
|
|
);
|
|
|
|
create table if not exists verification_requests (
|
|
id text primary key,
|
|
user_id text references users(id) on delete set null,
|
|
cookie_user_id text,
|
|
fingerprint_id text,
|
|
nickname text,
|
|
ip text,
|
|
socket_id text,
|
|
status text not null,
|
|
decision text,
|
|
created_at integer not null,
|
|
resolved_at integer,
|
|
resolved_by text,
|
|
legacy_json text
|
|
);
|
|
create index if not exists idx_verification_requests_user_status on verification_requests(user_id, status);
|
|
|
|
create table if not exists verification_dm_messages (
|
|
message_id text primary key,
|
|
request_id text not null references verification_requests(id) on delete cascade,
|
|
admin_discord_id text,
|
|
created_at integer not null
|
|
);
|
|
|
|
create table if not exists user_feature_state (
|
|
user_id text not null references users(id) on delete cascade,
|
|
namespace text not null,
|
|
data_json text not null,
|
|
created_at integer not null,
|
|
updated_at integer not null,
|
|
primary key (user_id, namespace)
|
|
);
|
|
|
|
create table if not exists user_permissions (
|
|
user_id text not null references users(id) on delete cascade,
|
|
permission_key text not null,
|
|
granted_at integer not null,
|
|
granted_by text,
|
|
primary key (user_id, permission_key)
|
|
);
|
|
create index if not exists idx_user_permissions_key on user_permissions(permission_key);
|
|
|
|
create table if not exists legacy_imports (
|
|
source text not null,
|
|
legacy_id text not null,
|
|
user_id text references users(id) on delete set null,
|
|
imported_at integer not null,
|
|
data_json text,
|
|
primary key (source, legacy_id)
|
|
);
|
|
|
|
pragma user_version = ${STORE_VERSION};
|
|
`);
|
|
|
|
/*
|
|
SQLite's `create table if not exists` leaves an existing table untouched.
|
|
Add the mute columns explicitly for installations created before store
|
|
version 2. Permission grants now live in their own normalized table, so the
|
|
obsolete audio-specific status columns are deliberately removed instead of
|
|
carrying old grants into the new capability system.
|
|
*/
|
|
const statusColumns = new Set(
|
|
conn.prepare('pragma table_info(user_status)').all().map((column) => column.name),
|
|
);
|
|
if (!statusColumns.has('muted_enabled')) {
|
|
conn.exec('alter table user_status add column muted_enabled integer not null default 0');
|
|
}
|
|
if (!statusColumns.has('muted_at')) {
|
|
conn.exec('alter table user_status add column muted_at integer');
|
|
}
|
|
if (!statusColumns.has('muted_by')) {
|
|
conn.exec('alter table user_status add column muted_by text');
|
|
}
|
|
['audio_gain_boost_enabled', 'audio_gain_boost_at', 'audio_gain_boost_by'].forEach((column) => {
|
|
if (statusColumns.has(column)) conn.exec(`alter table user_status drop column ${column}`);
|
|
});
|
|
// Old personal fractions were identity-backed feature state. The replacement
|
|
// is intentionally browser-local, so retaining these unreachable rows would
|
|
// make the database page imply that they still control runtime behavior.
|
|
conn.prepare('delete from user_feature_state where namespace = ?').run('audioGains');
|
|
}
|
|
|
|
function createUser(conn = getDb(), ts = nowMs()) {
|
|
const id = generateUserId();
|
|
conn.prepare('insert into users (id, created_at, updated_at, last_seen_at) values (?, ?, ?, ?)').run(id, ts, ts, ts);
|
|
conn.prepare('insert into user_status (user_id) values (?)').run(id);
|
|
return id;
|
|
}
|
|
|
|
function ensureUserStatus(conn, userId) {
|
|
conn.prepare('insert or ignore into user_status (user_id) values (?)').run(userId);
|
|
}
|
|
|
|
function findUserIdByCookie(conn, cookieUserId) {
|
|
const key = normalizeCookieUserId(cookieUserId);
|
|
if (!key) return null;
|
|
return conn.prepare('select user_id from user_cookie_ids where cookie_user_id = ?').get(key)?.user_id || null;
|
|
}
|
|
|
|
function findUserIdByFingerprint(conn, fingerprintId) {
|
|
const key = normalizeFingerprintId(fingerprintId);
|
|
if (!key) return null;
|
|
return conn.prepare('select user_id from user_fingerprint_ids where fingerprint_id = ?').get(key)?.user_id || null;
|
|
}
|
|
|
|
function mergeUsers(conn, targetUserId, sourceUserId) {
|
|
if (!targetUserId || !sourceUserId || targetUserId === sourceUserId) return targetUserId || sourceUserId || null;
|
|
const ts = nowMs();
|
|
|
|
/*
|
|
Identity equality is deliberately global: if two strong signals point at
|
|
different users, those records represent the same person and must converge.
|
|
Child tables are moved to the chosen target before the source row is deleted.
|
|
*/
|
|
conn.prepare('update or ignore user_cookie_ids set user_id = ? where user_id = ?').run(targetUserId, sourceUserId);
|
|
conn.prepare('delete from user_cookie_ids where user_id = ?').run(sourceUserId);
|
|
conn.prepare('update or ignore user_fingerprint_ids set user_id = ? where user_id = ?').run(targetUserId, sourceUserId);
|
|
conn.prepare('delete from user_fingerprint_ids where user_id = ?').run(sourceUserId);
|
|
conn.prepare('update or ignore user_nicknames set user_id = ? where user_id = ?').run(targetUserId, sourceUserId);
|
|
conn.prepare('delete from user_nicknames where user_id = ?').run(sourceUserId);
|
|
conn.prepare('update or ignore user_known_ips set user_id = ? where user_id = ?').run(targetUserId, sourceUserId);
|
|
conn.prepare('delete from user_known_ips where user_id = ?').run(sourceUserId);
|
|
conn.prepare('update verification_requests set user_id = ? where user_id = ?').run(targetUserId, sourceUserId);
|
|
conn.prepare('update legacy_imports set user_id = ? where user_id = ?').run(targetUserId, sourceUserId);
|
|
/*
|
|
Permissions describe the person, not one browser signal. Merging identities
|
|
therefore unions their grants before the source user is deleted; a conflict
|
|
keeps the target row and its original audit metadata.
|
|
*/
|
|
conn.prepare('update or ignore user_permissions set user_id = ? where user_id = ?').run(targetUserId, sourceUserId);
|
|
conn.prepare('delete from user_permissions where user_id = ?').run(sourceUserId);
|
|
|
|
const sourceStatus = conn.prepare('select * from user_status where user_id = ?').get(sourceUserId);
|
|
ensureUserStatus(conn, targetUserId);
|
|
if (sourceStatus?.verified_enabled) {
|
|
conn.prepare(`
|
|
update user_status
|
|
set verified_enabled = 1,
|
|
verified_at = coalesce(verified_at, ?),
|
|
verified_by = coalesce(verified_by, ?)
|
|
where user_id = ?
|
|
`).run(sourceStatus.verified_at || ts, sourceStatus.verified_by || null, targetUserId);
|
|
}
|
|
if (sourceStatus?.deterrence_enabled) {
|
|
conn.prepare(`
|
|
update user_status
|
|
set deterrence_enabled = 1,
|
|
deterrence_reason = coalesce(deterrence_reason, ?),
|
|
deterrence_at = coalesce(deterrence_at, ?),
|
|
deterrence_by = coalesce(deterrence_by, ?)
|
|
where user_id = ?
|
|
`).run(sourceStatus.deterrence_reason || null, sourceStatus.deterrence_at || ts, sourceStatus.deterrence_by || null, targetUserId);
|
|
}
|
|
if (sourceStatus?.muted_enabled) {
|
|
/*
|
|
Identity merging must preserve the stricter moderation state. Otherwise
|
|
joining two signals could silently clear a mute merely because the
|
|
unmuted record happened to become the merge target.
|
|
*/
|
|
conn.prepare(`
|
|
update user_status
|
|
set muted_enabled = 1,
|
|
muted_at = coalesce(muted_at, ?),
|
|
muted_by = coalesce(muted_by, ?)
|
|
where user_id = ?
|
|
`).run(sourceStatus.muted_at || ts, sourceStatus.muted_by || null, targetUserId);
|
|
}
|
|
|
|
const sourceFeatures = conn.prepare('select namespace, data_json, created_at, updated_at from user_feature_state where user_id = ?').all(sourceUserId);
|
|
sourceFeatures.forEach((feature) => {
|
|
const existing = conn.prepare('select data_json, created_at from user_feature_state where user_id = ? and namespace = ?').get(targetUserId, feature.namespace);
|
|
if (!existing) {
|
|
conn.prepare(`
|
|
insert into user_feature_state (user_id, namespace, data_json, created_at, updated_at)
|
|
values (?, ?, ?, ?, ?)
|
|
`).run(targetUserId, feature.namespace, feature.data_json, feature.created_at || ts, feature.updated_at || ts);
|
|
return;
|
|
}
|
|
const merged = {
|
|
...(decodeJson(existing.data_json, {}) || {}),
|
|
...(decodeJson(feature.data_json, {}) || {}),
|
|
};
|
|
conn.prepare('update user_feature_state set data_json = ?, updated_at = ? where user_id = ? and namespace = ?')
|
|
.run(encodeJson(merged), ts, targetUserId, feature.namespace);
|
|
});
|
|
|
|
conn.prepare('delete from user_feature_state where user_id = ?').run(sourceUserId);
|
|
conn.prepare('delete from user_status where user_id = ?').run(sourceUserId);
|
|
conn.prepare('delete from users where id = ?').run(sourceUserId);
|
|
conn.prepare('update users set updated_at = ? where id = ?').run(ts, targetUserId);
|
|
return targetUserId;
|
|
}
|
|
|
|
function resolveUserIdForIdentity(identity = {}, { create = true, conn = getDb() } = {}) {
|
|
const cookieUserId = normalizeCookieUserId(identity.cookieUserId);
|
|
const fingerprintId = normalizeFingerprintId(identity.fingerprintId);
|
|
const cookieUser = cookieUserId ? findUserIdByCookie(conn, cookieUserId) : null;
|
|
const fingerprintUser = fingerprintId ? findUserIdByFingerprint(conn, fingerprintId) : null;
|
|
|
|
if (cookieUser && fingerprintUser) {
|
|
return cookieUser === fingerprintUser ? cookieUser : mergeUsers(conn, cookieUser, fingerprintUser);
|
|
}
|
|
if (cookieUser || fingerprintUser) return cookieUser || fingerprintUser;
|
|
return create ? createUser(conn) : null;
|
|
}
|
|
|
|
function attachIdentitySignals(userId, identity = {}, { conn = getDb(), ts = nowMs() } = {}) {
|
|
if (!userId) return null;
|
|
ensureUserStatus(conn, userId);
|
|
|
|
const cookieUserId = normalizeCookieUserId(identity.cookieUserId);
|
|
if (cookieUserId && isValidCookieUserId(cookieUserId)) {
|
|
conn.prepare(`
|
|
insert into user_cookie_ids (cookie_user_id, user_id, created_at, last_seen_at)
|
|
values (?, ?, ?, ?)
|
|
on conflict(cookie_user_id) do update set user_id = excluded.user_id, last_seen_at = excluded.last_seen_at
|
|
`).run(cookieUserId, userId, ts, ts);
|
|
}
|
|
|
|
const fingerprintId = normalizeFingerprintId(identity.fingerprintId);
|
|
if (fingerprintId && isValidFingerprintId(fingerprintId)) {
|
|
conn.prepare(`
|
|
insert into user_fingerprint_ids (fingerprint_id, user_id, created_at, last_seen_at)
|
|
values (?, ?, ?, ?)
|
|
on conflict(fingerprint_id) do update set user_id = excluded.user_id, last_seen_at = excluded.last_seen_at
|
|
`).run(fingerprintId, userId, ts, ts);
|
|
}
|
|
|
|
const nickname = sanitizeNickname(identity.nickname);
|
|
if (nickname) {
|
|
conn.prepare(`
|
|
insert into user_nicknames (user_id, nickname, first_seen_at, last_seen_at)
|
|
values (?, ?, ?, ?)
|
|
on conflict(user_id, nickname) do update set last_seen_at = excluded.last_seen_at
|
|
`).run(userId, nickname, ts, ts);
|
|
}
|
|
|
|
const ip = normalizeIp(identity.ip);
|
|
if (ip) {
|
|
conn.prepare(`
|
|
insert into user_known_ips (user_id, ip, first_seen_at, last_seen_at)
|
|
values (?, ?, ?, ?)
|
|
on conflict(user_id, ip) do update set last_seen_at = excluded.last_seen_at
|
|
`).run(userId, ip, ts, ts);
|
|
}
|
|
|
|
conn.prepare('update users set updated_at = ?, last_seen_at = ? where id = ?').run(ts, ts, userId);
|
|
return getUserById(userId, { conn });
|
|
}
|
|
|
|
function normalizeSocketIdentity(socket, payload = {}) {
|
|
const data = socket?.data || {};
|
|
return {
|
|
cookieUserId: normalizeCookieUserId(payload.cookieUserId || data.cookieUserId),
|
|
fingerprintId: normalizeFingerprintId(payload.fingerprintId || data.fingerprintId),
|
|
nickname: sanitizeNickname(payload.nickname || data.nickname),
|
|
ip: payload.ip || getKnownIp(socket),
|
|
};
|
|
}
|
|
|
|
function setSocketIdentityState(socket, user, identity = {}) {
|
|
if (!socket || !user) return;
|
|
socket.data = socket.data || {};
|
|
socket.data.userId = user.id;
|
|
socket.data.cookieUserId = normalizeCookieUserId(identity.cookieUserId) || user.cookieUserIds[0] || '';
|
|
socket.data.fingerprintId = normalizeFingerprintId(identity.fingerprintId) || user.fingerprintIds[0] || '';
|
|
socket.data.isVerified = Boolean(user.verified?.enabled);
|
|
socket.data.verifiedRecordId = user.verified?.enabled ? user.id : null;
|
|
socket.data.isDeterred = Boolean(user.deterrence?.enabled);
|
|
socket.data.deterredRecordId = user.deterrence?.enabled ? user.id : null;
|
|
socket.data.isMuted = Boolean(user.deterrence?.muted);
|
|
}
|
|
|
|
function identifySocket(socket, payload = {}) {
|
|
if (!socket) throw new Error('Socket required');
|
|
const conn = getDb();
|
|
const ts = nowMs();
|
|
socket.data = socket.data || {};
|
|
|
|
let cookieUserId = normalizeCookieUserId(payload.cookieUserId || socket.data.cookieUserId);
|
|
if (cookieUserId && !isValidCookieUserId(cookieUserId)) {
|
|
throw new Error('Invalid identity key format.');
|
|
}
|
|
if (!cookieUserId) cookieUserId = generateCookieUserId();
|
|
|
|
const fingerprintId = normalizeFingerprintId(payload.fingerprintId || socket.data.fingerprintId);
|
|
if (fingerprintId && !isValidFingerprintId(fingerprintId)) {
|
|
throw new Error('Invalid fingerprint format.');
|
|
}
|
|
|
|
const identity = {
|
|
cookieUserId,
|
|
fingerprintId,
|
|
nickname: sanitizeNickname(payload.nickname || socket.data.nickname),
|
|
ip: getKnownIp(socket),
|
|
};
|
|
|
|
const user = conn.transaction(() => {
|
|
const userId = resolveUserIdForIdentity(identity, { create: true, conn });
|
|
return attachIdentitySignals(userId, identity, { conn, ts });
|
|
})();
|
|
|
|
setSocketIdentityState(socket, user, identity);
|
|
identityEvents.emit('change', { reason: 'identify', socketId: socket.id, userId: user.id });
|
|
return {
|
|
user,
|
|
userId: user.id,
|
|
cookieUserId,
|
|
fingerprintId: fingerprintId || null,
|
|
isVerified: Boolean(user.verified?.enabled),
|
|
isDeterred: Boolean(user.deterrence?.enabled),
|
|
isMuted: Boolean(user.deterrence?.muted),
|
|
};
|
|
}
|
|
|
|
function getRows(conn, sql, params = []) {
|
|
return conn.prepare(sql).all(...params);
|
|
}
|
|
|
|
function getUserById(userId, { conn = getDb(), includeFeatures = true } = {}) {
|
|
const id = String(userId || '').trim();
|
|
if (!id) return null;
|
|
const row = conn.prepare('select * from users where id = ?').get(id);
|
|
if (!row) return null;
|
|
const status = conn.prepare('select * from user_status where user_id = ?').get(id) || {};
|
|
const features = {};
|
|
if (includeFeatures) {
|
|
getRows(conn, 'select namespace, data_json from user_feature_state where user_id = ?', [id]).forEach((feature) => {
|
|
features[feature.namespace] = decodeJson(feature.data_json, {});
|
|
});
|
|
}
|
|
const nicknames = getRows(conn, 'select nickname from user_nicknames where user_id = ? order by last_seen_at desc', [id])
|
|
.map((entry) => entry.nickname);
|
|
const knownIps = getRows(conn, 'select ip from user_known_ips where user_id = ? order by last_seen_at desc', [id])
|
|
.map((entry) => entry.ip);
|
|
return {
|
|
id,
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at,
|
|
lastSeenAt: row.last_seen_at,
|
|
cookieUserIds: getRows(conn, 'select cookie_user_id from user_cookie_ids where user_id = ? order by last_seen_at desc', [id])
|
|
.map((entry) => entry.cookie_user_id),
|
|
fingerprintIds: getRows(conn, 'select fingerprint_id from user_fingerprint_ids where user_id = ? order by last_seen_at desc', [id])
|
|
.map((entry) => entry.fingerprint_id),
|
|
nicknames,
|
|
knownIps,
|
|
nickname: nicknames[0] || null,
|
|
knownIp: knownIps[0] || null,
|
|
verified: {
|
|
enabled: Boolean(status.verified_enabled),
|
|
at: status.verified_at || null,
|
|
by: status.verified_by || null,
|
|
},
|
|
deterrence: {
|
|
enabled: Boolean(status.deterrence_enabled),
|
|
reason: status.deterrence_reason || null,
|
|
at: status.deterrence_at || null,
|
|
by: status.deterrence_by || null,
|
|
muted: Boolean(status.muted_enabled),
|
|
mutedAt: status.muted_at || null,
|
|
mutedBy: status.muted_by || null,
|
|
},
|
|
permissions: getUserPermissions(id, { conn }),
|
|
features,
|
|
};
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
function getUserIdForSocket(socket) {
|
|
return socket?.data?.userId || null;
|
|
}
|
|
|
|
function getIdentitySummary(socket) {
|
|
const user = getUserForSocket(socket);
|
|
const data = socket?.data || {};
|
|
return {
|
|
userId: user?.id || data.userId || null,
|
|
cookieUserId: normalizeCookieUserId(data.cookieUserId) || user?.cookieUserIds?.[0] || null,
|
|
fingerprintId: normalizeFingerprintId(data.fingerprintId) || user?.fingerprintIds?.[0] || null,
|
|
nickname: user?.nickname || null,
|
|
overseerEnabled: typeof data.overseerEnabled === 'boolean' ? data.overseerEnabled : true,
|
|
};
|
|
}
|
|
|
|
function getFeatureState(userId, namespace, defaults = {}) {
|
|
const id = String(userId || '').trim();
|
|
const ns = String(namespace || '').trim();
|
|
if (!id || !ns) return defaults;
|
|
const row = getDb().prepare('select data_json from user_feature_state where user_id = ? and namespace = ?').get(id, ns);
|
|
return row ? decodeJson(row.data_json, defaults) : defaults;
|
|
}
|
|
|
|
function setFeatureState(userId, namespace, nextState) {
|
|
const id = String(userId || '').trim();
|
|
const ns = String(namespace || '').trim();
|
|
if (!id || !ns) throw new Error('userId and namespace required');
|
|
const ts = nowMs();
|
|
getDb().prepare(`
|
|
insert into user_feature_state (user_id, namespace, data_json, created_at, updated_at)
|
|
values (?, ?, ?, ?, ?)
|
|
on conflict(user_id, namespace) do update set data_json = excluded.data_json, updated_at = excluded.updated_at
|
|
`).run(id, ns, encodeJson(nextState || {}), ts, ts);
|
|
identityEvents.emit('change', { reason: 'feature_state', userId: id, namespace: ns });
|
|
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;
|
|
return setFeatureState(userId, namespace, next);
|
|
}
|
|
|
|
function listFeatureStates(namespace) {
|
|
const ns = String(namespace || '').trim();
|
|
if (!ns) return [];
|
|
return getDb().prepare(`
|
|
select user_id, data_json, created_at, updated_at
|
|
from user_feature_state
|
|
where namespace = ?
|
|
order by updated_at desc
|
|
`).all(ns).map((row) => ({
|
|
userId: row.user_id,
|
|
state: decodeJson(row.data_json, {}),
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at,
|
|
}));
|
|
}
|
|
|
|
function setVerified(userId, { enabled = true, actor = null, at = nowMs() } = {}) {
|
|
const id = String(userId || '').trim();
|
|
if (!id) throw new Error('userId required');
|
|
ensureUserStatus(getDb(), id);
|
|
getDb().prepare(`
|
|
update user_status
|
|
set verified_enabled = ?, verified_at = ?, verified_by = ?
|
|
where user_id = ?
|
|
`).run(enabled ? 1 : 0, enabled ? at : null, enabled ? actor : null, id);
|
|
identityEvents.emit('change', { reason: enabled ? 'verified' : 'verification_removed', userId: id });
|
|
return getUserById(id);
|
|
}
|
|
|
|
function setDeterrence(userId, { enabled = true, reason = null, actor = null, at = nowMs() } = {}) {
|
|
const id = String(userId || '').trim();
|
|
if (!id) throw new Error('userId required');
|
|
ensureUserStatus(getDb(), id);
|
|
getDb().prepare(`
|
|
update user_status
|
|
set deterrence_enabled = ?, deterrence_reason = ?, deterrence_at = ?, deterrence_by = ?
|
|
where user_id = ?
|
|
`).run(enabled ? 1 : 0, enabled ? reason : null, enabled ? at : null, enabled ? actor : null, id);
|
|
identityEvents.emit('change', { reason: enabled ? 'deterred' : 'undeterred', userId: id });
|
|
return getUserById(id);
|
|
}
|
|
|
|
function setMuted(userId, { enabled = true, actor = null, at = nowMs() } = {}) {
|
|
const id = String(userId || '').trim();
|
|
if (!id) throw new Error('userId required');
|
|
ensureUserStatus(getDb(), id);
|
|
getDb().prepare(`
|
|
update user_status
|
|
set muted_enabled = ?, muted_at = ?, muted_by = ?
|
|
where user_id = ?
|
|
`).run(enabled ? 1 : 0, enabled ? at : null, enabled ? actor : null, id);
|
|
identityEvents.emit('change', { reason: enabled ? 'muted' : 'unmuted', userId: id });
|
|
return getUserById(id);
|
|
}
|
|
|
|
/*
|
|
Positive capabilities use normalized rows rather than feature-specific status
|
|
columns. This keeps moderation state focused and gives future permissions the
|
|
same audited grant/revoke path without another schema alteration.
|
|
*/
|
|
function setUserPermission(userId, permissionKey, { enabled = true, actor = null, at = nowMs() } = {}) {
|
|
const id = String(userId || '').trim();
|
|
if (!id) throw new Error('userId required');
|
|
const permission = requireRegisteredPermission(permissionKey);
|
|
const conn = getDb();
|
|
if (!conn.prepare('select 1 from users where id = ?').get(id)) throw new Error('User not found.');
|
|
if (enabled) {
|
|
conn.prepare(`
|
|
insert into user_permissions (user_id, permission_key, granted_at, granted_by)
|
|
values (?, ?, ?, ?)
|
|
on conflict(user_id, permission_key) do update set granted_at = excluded.granted_at, granted_by = excluded.granted_by
|
|
`).run(id, permission.key, at, actor ? String(actor) : null);
|
|
} else {
|
|
conn.prepare('delete from user_permissions where user_id = ? and permission_key = ?').run(id, permission.key);
|
|
}
|
|
identityEvents.emit('change', {
|
|
reason: enabled ? 'permission_granted' : 'permission_revoked',
|
|
userId: id,
|
|
permissionKey: permission.key,
|
|
});
|
|
conn.prepare('update users set updated_at = ? where id = ?').run(at, id);
|
|
return getUserById(id);
|
|
}
|
|
|
|
function getUserPermissions(userId, { conn = getDb() } = {}) {
|
|
const id = String(userId || '').trim();
|
|
if (!id) return [];
|
|
return conn.prepare(`
|
|
select permission_key as key, granted_at as grantedAt, granted_by as grantedBy
|
|
from user_permissions
|
|
where user_id = ?
|
|
order by permission_key
|
|
`).all(id);
|
|
}
|
|
|
|
function hasUserPermission(userId, permissionKey, { conn = getDb() } = {}) {
|
|
const id = String(userId || '').trim();
|
|
const permission = requireRegisteredPermission(permissionKey);
|
|
if (!id) return false;
|
|
return Boolean(conn.prepare('select 1 from user_permissions where user_id = ? and permission_key = ?').get(id, permission.key));
|
|
}
|
|
|
|
function listUsersWithPermission(permissionKey) {
|
|
const permission = requireRegisteredPermission(permissionKey);
|
|
const conn = getDb();
|
|
return conn.prepare('select user_id from user_permissions where permission_key = ? order by granted_at desc')
|
|
.all(permission.key)
|
|
.map((row) => getUserById(row.user_id, { conn, includeFeatures: false }))
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function isVerified(socket) {
|
|
return Boolean(socket?.data?.isVerified);
|
|
}
|
|
|
|
function isDeterred(socket) {
|
|
return Boolean(socket?.data?.isDeterred);
|
|
}
|
|
|
|
function listUsers({ verified = null, deterred = null, muted = null } = {}) {
|
|
const conn = getDb();
|
|
let sql = 'select users.id from users join user_status on user_status.user_id = users.id';
|
|
const where = [];
|
|
if (verified !== null) where.push(`user_status.verified_enabled = ${verified ? 1 : 0}`);
|
|
if (deterred !== null) where.push(`user_status.deterrence_enabled = ${deterred ? 1 : 0}`);
|
|
if (muted !== null) where.push(`user_status.muted_enabled = ${muted ? 1 : 0}`);
|
|
if (where.length) sql += ` where ${where.join(' and ')}`;
|
|
sql += ' order by users.updated_at desc';
|
|
return conn.prepare(sql).all().map((row) => getUserById(row.id, { conn, includeFeatures: false }));
|
|
}
|
|
|
|
function userToLegacyIdentityEntry(user) {
|
|
return {
|
|
id: user.id,
|
|
userId: user.id,
|
|
cookieUserId: user.cookieUserIds[0] || null,
|
|
fingerprintId: user.fingerprintIds[0] || null,
|
|
fingerprintIds: user.fingerprintIds,
|
|
nickname: user.nickname || null,
|
|
knownIps: user.knownIps,
|
|
createdAt: user.createdAt,
|
|
updatedAt: user.updatedAt,
|
|
approvedBy: user.verified?.by || null,
|
|
reason: user.deterrence?.reason || null,
|
|
muted: Boolean(user.deterrence?.muted),
|
|
mutedAt: user.deterrence?.mutedAt || null,
|
|
mutedBy: user.deterrence?.mutedBy || null,
|
|
permissions: (user.permissions || []).map((permission) => permission.key),
|
|
};
|
|
}
|
|
|
|
function listVerifiedUsers() {
|
|
return listUsers({ verified: true }).map(userToLegacyIdentityEntry);
|
|
}
|
|
|
|
function listDeterredUsers() {
|
|
return listUsers({ deterred: true }).map(userToLegacyIdentityEntry);
|
|
}
|
|
|
|
function listMutedUsers() {
|
|
return listUsers({ muted: true }).map(userToLegacyIdentityEntry);
|
|
}
|
|
|
|
function resolveUserBySelector(selector, { includeDeterred = true, includeVerified = true } = {}) {
|
|
const value = String(selector || '').trim();
|
|
if (!value) return { error: 'selector_required' };
|
|
const conn = getDb();
|
|
|
|
if (isValidUserId(value)) {
|
|
const user = getUserById(value, { conn });
|
|
if (user) return { user };
|
|
}
|
|
|
|
const cookie = normalizeCookieUserId(value);
|
|
if (cookie && isValidCookieUserId(cookie)) {
|
|
const userId = findUserIdByCookie(conn, cookie);
|
|
if (userId) return { user: getUserById(userId, { conn }) };
|
|
}
|
|
|
|
const fingerprint = normalizeFingerprintId(value);
|
|
if (fingerprint && isValidFingerprintId(fingerprint)) {
|
|
const userId = findUserIdByFingerprint(conn, fingerprint);
|
|
if (userId) return { user: getUserById(userId, { conn }) };
|
|
}
|
|
|
|
const nickname = sanitizeNickname(value);
|
|
if (nickname) {
|
|
const rows = conn.prepare(`
|
|
select distinct user_nicknames.user_id
|
|
from user_nicknames
|
|
join user_status on user_status.user_id = user_nicknames.user_id
|
|
where lower(user_nicknames.nickname) = lower(?)
|
|
and (? = 1 or user_status.verified_enabled = 1)
|
|
and (? = 1 or user_status.deterrence_enabled = 1)
|
|
`).all(nickname, includeVerified ? 1 : 0, includeDeterred ? 1 : 0);
|
|
if (rows.length === 1) return { user: getUserById(rows[0].user_id, { conn }) };
|
|
if (rows.length > 1) return { error: 'ambiguous_nickname' };
|
|
}
|
|
|
|
return { error: 'not_found' };
|
|
}
|
|
|
|
function recordLegacyImport(conn, source, legacyId, userId, data) {
|
|
const id = String(legacyId || '').trim();
|
|
if (!source || !id) return;
|
|
conn.prepare(`
|
|
insert or ignore into legacy_imports (source, legacy_id, user_id, imported_at, data_json)
|
|
values (?, ?, ?, ?, ?)
|
|
`).run(source, id, userId || null, nowMs(), encodeJson(data || {}));
|
|
}
|
|
|
|
function importVerifiedUsers(conn, legacyStore) {
|
|
(Array.isArray(legacyStore.verifiedUsers) ? legacyStore.verifiedUsers : []).forEach((entry) => {
|
|
const cookieUserId = normalizeCookieUserId(entry.cookieUserId);
|
|
const identity = {
|
|
cookieUserId: isValidCookieUserId(cookieUserId) ? cookieUserId : '',
|
|
nickname: entry.nickname,
|
|
ip: Array.isArray(entry.knownIps) ? entry.knownIps[0] : null,
|
|
};
|
|
const userId = resolveUserIdForIdentity(identity, { create: true, conn });
|
|
attachIdentitySignals(userId, identity, { conn, ts: entry.updatedAt || entry.createdAt || nowMs() });
|
|
(Array.isArray(entry.knownIps) ? entry.knownIps : []).forEach((ip) => {
|
|
attachIdentitySignals(userId, { ip }, { conn, ts: entry.updatedAt || nowMs() });
|
|
});
|
|
setVerified(userId, {
|
|
enabled: true,
|
|
actor: entry.approvedBy || null,
|
|
at: entry.createdAt || entry.updatedAt || nowMs(),
|
|
});
|
|
recordLegacyImport(conn, 'verifiedUsers', entry.id || cookieUserId, userId, entry);
|
|
});
|
|
}
|
|
|
|
function importDeterredUsers(conn, legacyStore) {
|
|
(Array.isArray(legacyStore.deterredUsers) ? legacyStore.deterredUsers : []).forEach((entry) => {
|
|
const cookieUserId = normalizeCookieUserId(entry.cookieUserId);
|
|
const identity = {
|
|
cookieUserId: isValidCookieUserId(cookieUserId) ? cookieUserId : '',
|
|
nickname: entry.nickname,
|
|
ip: Array.isArray(entry.knownIps) ? entry.knownIps[0] : null,
|
|
};
|
|
const userId = resolveUserIdForIdentity(identity, { create: true, conn });
|
|
attachIdentitySignals(userId, identity, { conn, ts: entry.updatedAt || entry.createdAt || nowMs() });
|
|
(Array.isArray(entry.knownIps) ? entry.knownIps : []).forEach((ip) => {
|
|
attachIdentitySignals(userId, { ip }, { conn, ts: entry.updatedAt || nowMs() });
|
|
});
|
|
setDeterrence(userId, {
|
|
enabled: true,
|
|
reason: entry.reason || null,
|
|
actor: entry.updatedBy || entry.createdBy || null,
|
|
at: entry.updatedAt || entry.createdAt || nowMs(),
|
|
});
|
|
recordLegacyImport(conn, 'deterredUsers', entry.id || cookieUserId || crypto.randomBytes(8).toString('hex'), userId, entry);
|
|
});
|
|
}
|
|
|
|
function importVerificationRequests(conn, legacyStore) {
|
|
(Array.isArray(legacyStore.pendingRequests) ? legacyStore.pendingRequests : []).forEach((request) => {
|
|
const cookieUserId = normalizeCookieUserId(request.cookieUserId);
|
|
const userId = cookieUserId && isValidCookieUserId(cookieUserId)
|
|
? resolveUserIdForIdentity({ cookieUserId, nickname: request.nickname, ip: request.ip }, { create: true, conn })
|
|
: null;
|
|
if (userId) attachIdentitySignals(userId, { cookieUserId, nickname: request.nickname, ip: request.ip }, { conn, ts: request.createdAt || nowMs() });
|
|
conn.prepare(`
|
|
insert or ignore into verification_requests
|
|
(id, user_id, cookie_user_id, fingerprint_id, nickname, ip, socket_id, status, decision, created_at, resolved_at, resolved_by, legacy_json)
|
|
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`).run(
|
|
request.id,
|
|
userId,
|
|
cookieUserId || null,
|
|
normalizeFingerprintId(request.fingerprintId) || null,
|
|
sanitizeNickname(request.nickname) || null,
|
|
normalizeIp(request.ip) || null,
|
|
request.socketId || null,
|
|
request.status || 'pending',
|
|
request.decision || null,
|
|
request.createdAt || nowMs(),
|
|
request.resolvedAt || null,
|
|
request.resolvedBy || null,
|
|
encodeJson(request),
|
|
);
|
|
recordLegacyImport(conn, 'pendingRequests', request.id, userId, request);
|
|
});
|
|
|
|
(Array.isArray(legacyStore.dmMessages) ? legacyStore.dmMessages : []).forEach((entry) => {
|
|
if (!entry.messageId || !entry.requestId) return;
|
|
conn.prepare(`
|
|
insert or ignore into verification_dm_messages (message_id, request_id, admin_discord_id, created_at)
|
|
values (?, ?, ?, ?)
|
|
`).run(entry.messageId, entry.requestId, entry.adminDiscordId || null, entry.createdAt || nowMs());
|
|
recordLegacyImport(conn, 'verificationDmMessages', entry.messageId, null, entry);
|
|
});
|
|
}
|
|
|
|
function importBarcodePlayers(conn, barcodeStore) {
|
|
const players = barcodeStore?.players && typeof barcodeStore.players === 'object' ? barcodeStore.players : {};
|
|
Object.entries(players).forEach(([playerKey, player]) => {
|
|
const fromField = normalizeCookieUserId(player?.cookieUserId);
|
|
const fromKey = String(playerKey || '').startsWith('identity:')
|
|
? normalizeCookieUserId(String(playerKey).slice('identity:'.length))
|
|
: '';
|
|
const cookieUserId = isValidCookieUserId(fromField) ? fromField : isValidCookieUserId(fromKey) ? fromKey : '';
|
|
if (!cookieUserId) {
|
|
recordLegacyImport(conn, 'barcodePlayersOrphan', playerKey, null, player);
|
|
return;
|
|
}
|
|
const userId = resolveUserIdForIdentity({ cookieUserId, nickname: player.nickname }, { create: true, conn });
|
|
attachIdentitySignals(userId, { cookieUserId, nickname: player.nickname }, { conn, ts: player.lastSeenAt || nowMs() });
|
|
updateFeatureState(userId, 'barcodeGames', (current) => ({
|
|
...(current || {}),
|
|
playerKeys: Array.from(new Set([...(current?.playerKeys || []), playerKey])),
|
|
cookieUserId,
|
|
nickname: player.nickname || current?.nickname || null,
|
|
lastRoverId: player.lastRoverId || current?.lastRoverId || null,
|
|
totalPoints: Math.max(Number(current?.totalPoints || 0), Number(player.totalPoints || 0)),
|
|
lastSeenAt: Math.max(Number(current?.lastSeenAt || 0), Number(player.lastSeenAt || 0)) || null,
|
|
games: {
|
|
...(current?.games || {}),
|
|
...(player.games || {}),
|
|
},
|
|
}), {});
|
|
recordLegacyImport(conn, 'barcodePlayers', playerKey, userId, player);
|
|
});
|
|
}
|
|
|
|
function migrateLegacyStores(conn) {
|
|
const migrated = conn.transaction(() => {
|
|
const legacyVerification = readJsonFile(LEGACY_VERIFICATION_PATH, {});
|
|
const legacyBarcode = readJsonFile(LEGACY_BARCODE_PATH, {});
|
|
importVerifiedUsers(conn, legacyVerification);
|
|
importDeterredUsers(conn, legacyVerification);
|
|
importVerificationRequests(conn, legacyVerification);
|
|
importBarcodePlayers(conn, legacyBarcode);
|
|
});
|
|
|
|
migrated();
|
|
logger.info('Identity SQLite store initialized from legacy files once', { path: DB_PATH });
|
|
}
|
|
|
|
/*
|
|
createJsonStore remains exported for the older non-identity stores that use a
|
|
small JSON file. It is not used by the new user identity database.
|
|
*/
|
|
function createJsonStore({ path: filePath, normalizeStoreShape, cloneStore, logger: storeLogger }) {
|
|
let cache = null;
|
|
|
|
function loadStore() {
|
|
if (cache) return cache;
|
|
try {
|
|
const raw = fs.readFileSync(filePath, 'utf8');
|
|
cache = normalizeStoreShape(JSON.parse(raw));
|
|
} catch (err) {
|
|
if (err.code !== 'ENOENT') {
|
|
storeLogger?.warn?.('Failed to load JSON store', { path: filePath, error: err.message });
|
|
}
|
|
cache = normalizeStoreShape({});
|
|
}
|
|
return cache;
|
|
}
|
|
|
|
function writeStore(next) {
|
|
const normalized = normalizeStoreShape(next);
|
|
fs.mkdirSync(require('path').dirname(filePath), { recursive: true });
|
|
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
fs.writeFileSync(tempPath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8');
|
|
fs.renameSync(tempPath, filePath);
|
|
cache = normalized;
|
|
return cache;
|
|
}
|
|
|
|
function withStore(mutator) {
|
|
const current = loadStore();
|
|
const draft = cloneStore(current);
|
|
const result = mutator(draft);
|
|
writeStore(draft);
|
|
return result;
|
|
}
|
|
|
|
return {
|
|
loadStore,
|
|
writeStore,
|
|
withStore,
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
identityEvents,
|
|
getDb,
|
|
sanitizeNickname,
|
|
normalizeCookieUserId,
|
|
isValidCookieUserId,
|
|
generateCookieUserId,
|
|
normalizeFingerprintId,
|
|
isValidFingerprintId,
|
|
generateUserId,
|
|
getKnownIp,
|
|
identifySocket,
|
|
normalizeSocketIdentity,
|
|
resolveUserIdForIdentity,
|
|
attachIdentitySignals,
|
|
getUserById,
|
|
listUsersForAdmin,
|
|
getUserForAdmin,
|
|
addUserSignal,
|
|
removeUserSignal,
|
|
getUserForSocket,
|
|
getUserIdForSocket,
|
|
getIdentitySummary,
|
|
getFeatureState,
|
|
setFeatureState,
|
|
deleteFeatureState,
|
|
updateFeatureState,
|
|
listFeatureStates,
|
|
setVerified,
|
|
setDeterrence,
|
|
setMuted,
|
|
setUserPermission,
|
|
getUserPermissions,
|
|
hasUserPermission,
|
|
listUsersWithPermission,
|
|
listRegisteredPermissions,
|
|
isVerified,
|
|
isDeterred,
|
|
listVerifiedUsers,
|
|
listDeterredUsers,
|
|
listMutedUsers,
|
|
resolveUserBySelector,
|
|
userToLegacyIdentityEntry,
|
|
createJsonStore,
|
|
};
|