here goes nothin'

This commit is contained in:
legop3
2026-06-27 04:23:58 -04:00
parent 0172ea5150
commit 3c1b1dab6e
21 changed files with 1554 additions and 332 deletions
+6
View File
@@ -17,6 +17,7 @@ server/data/discord-guilds.json
server/data/global-objective.json
server/data/admin-reason.json
server/data/buttonbox-state.json
server/data/barcode-tts-cache/
webui/package-lock.json
!server/data/
!server/data/barcode-registry.json
@@ -24,3 +25,8 @@ webui/src/config/analytics.jsx
webui/src/config/driverAnalytics.json
webui/src/config/analytics.html
plans/barcodegames.txt
.gitignore
server/data/identity.sqlite
server/data/barcode-games.json
server/data/identity.sqlite-shm
server/data/identity.sqlite-wal
+1
View File
@@ -9,6 +9,7 @@
},
"dependencies": {
"bcrypt": "^6.0.0",
"better-sqlite3": "^12.11.1",
"discord.js": "^14.25.1",
"express": "^4.19.2",
"fuse.js": "^7.4.2",
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -78,7 +78,7 @@
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
<title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-DU0INlO8.js"></script>
<script type="module" crossorigin src="/assets/index-BoGJudGZ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CKlOAshP.css">
</head>
<body>
+89 -12
View File
@@ -10,6 +10,7 @@ const { subscribe } = require('../eventBus');
const { sendSystemMessage } = require('../chatService');
const { getActiveDrivers } = require('../turnService');
const { getIdentitySummary } = require('../verificationService');
const { getFeatureState, listFeatureStates, updateFeatureState } = require('../identityService');
const { getRegistrySnapshot } = require('../barcodeScannerService');
const { loadStore, withGameStore } = require('./store');
const scanQuest = require('./games/scanQuest');
@@ -241,7 +242,7 @@ function getKnownObjects() {
}
function normalizePlayerKey(identity = {}, socketId = '', roverId = '') {
if (identity.cookieUserId) return `identity:${identity.cookieUserId}`;
if (identity.userId) return `identity:${identity.userId}`;
if (socketId) return `socket:${socketId}`;
if (roverId) return `rover:${roverId}`;
return null;
@@ -250,7 +251,7 @@ function normalizePlayerKey(identity = {}, socketId = '', roverId = '') {
function normalizeIdentityPlayerKey(identity = {}) {
// Permanent scoring is identity-only. Socket and rover IDs are useful runtime
// evidence, but they are unstable and should not create leaderboard entries.
return identity.cookieUserId ? `identity:${identity.cookieUserId}` : null;
return identity.userId ? `identity:${identity.userId}` : null;
}
function resolveRoverParticipant(roverId) {
@@ -271,6 +272,7 @@ function resolveRoverParticipant(roverId) {
playerKey,
roverId: normalizedRoverId,
socketId,
userId: identity.userId || null,
cookieUserId: identity.cookieUserId || null,
nickname: identity.nickname || normalizedRoverId,
};
@@ -312,6 +314,7 @@ function recordRoundParticipant(draft, participant, now) {
playerKey: participant.playerKey || previous.playerKey || null,
roverId: participant.roverId || previous.roverId || null,
socketId: participant.socketId || previous.socketId || null,
userId: participant.userId || previous.userId || null,
cookieUserId: participant.cookieUserId || previous.cookieUserId || null,
nickname: participant.nickname || previous.nickname || participant.roverId || 'unknown player',
joinedAt: Number.isFinite(previous.joinedAt) ? previous.joinedAt : now,
@@ -332,6 +335,7 @@ function getRoundParticipants(draft) {
playerKey: participant.playerKey || null,
roverId: participant.roverId || null,
socketId: participant.socketId || null,
userId: participant.userId || null,
cookieUserId: participant.cookieUserId || null,
nickname: participant.nickname || participant.roverId || 'unknown player',
joinedAt: Number.isFinite(participant.joinedAt) ? participant.joinedAt : null,
@@ -349,6 +353,7 @@ function getProximityParticipants(draft, now) {
playerKey: sighting.playerKey,
roverId: sighting.roverId,
socketId: sighting.socketId || null,
userId: sighting.userId || null,
cookieUserId: sighting.cookieUserId || null,
nickname: sighting.nickname || sighting.roverId,
scannedAt: sighting.scannedAt,
@@ -407,6 +412,7 @@ function recordPlayerParticipation(draft, gameId, participants, now) {
const previousGame = previousGames[gameId] || {};
draft.players[participant.playerKey] = {
playerKey: participant.playerKey,
userId: participant.userId || previous.userId || null,
cookieUserId: participant.cookieUserId || previous.cookieUserId || null,
nickname: participant.nickname || previous.nickname || null,
lastRoverId: participant.roverId || previous.lastRoverId || null,
@@ -424,6 +430,11 @@ function recordPlayerParticipation(draft, gameId, participants, now) {
});
}
function userIdFromPlayerKey(playerKey) {
const value = String(playerKey || '').trim();
return value.startsWith('identity:usr_') ? value.slice('identity:'.length) : null;
}
function applyPointAwards(draft, gameId, awards = [], now) {
if (!gameId || !Array.isArray(awards) || !awards.length) return;
draft.players = draft.players || {};
@@ -443,6 +454,7 @@ function applyPointAwards(draft, gameId, awards = [], now) {
// one persistent place.
draft.players[playerKey] = {
playerKey,
userId: award.userId || previous.userId || userIdFromPlayerKey(playerKey),
cookieUserId: award.cookieUserId || previous.cookieUserId || null,
nickname: award.nickname || previous.nickname || null,
lastRoverId: award.roverId || previous.lastRoverId || null,
@@ -461,6 +473,38 @@ function applyPointAwards(draft, gameId, awards = [], now) {
},
};
const userId = draft.players[playerKey].userId;
if (userId) {
/*
The game store still owns current game state, but long-lived per-person
scoring is mirrored into identityService so future features can read one
canonical user object instead of scraping barcode-games.json.
*/
updateFeatureState(userId, 'barcodeGames', (current) => {
const currentGames = current?.games || {};
const currentGame = currentGames[gameId] || {};
return {
...(current || {}),
playerKeys: Array.from(new Set([...(current?.playerKeys || []), playerKey])),
nickname: draft.players[playerKey].nickname || current?.nickname || null,
lastRoverId: draft.players[playerKey].lastRoverId || current?.lastRoverId || null,
totalPoints: (Number.isFinite(current?.totalPoints) ? current.totalPoints : 0) + points,
lastSeenAt: now,
games: {
...currentGames,
[gameId]: {
...currentGame,
gameId,
points: (Number.isFinite(currentGame.points) ? currentGame.points : 0) + points,
awards: (Number.isFinite(currentGame.awards) ? currentGame.awards : 0) + 1,
lastAwardAt: now,
lastReason: award.reason || null,
},
},
};
}, {});
}
addRecentEvent(draft, {
kind: 'pointsAwarded',
gameId,
@@ -816,8 +860,9 @@ function getPlayerForSocket(store, socket) {
if (!socket) return null;
const identity = getIdentitySummary(socket);
const playerKey = normalizeIdentityPlayerKey(identity);
const featurePlayer = identity.userId ? getFeatureState(identity.userId, 'barcodeGames', null) : null;
const player = playerKey ? store.players?.[playerKey] || null : null;
if (!player) {
if (!player && !featurePlayer) {
return {
playerKey,
nickname: identity.nickname || null,
@@ -826,16 +871,26 @@ function getPlayerForSocket(store, socket) {
games: {},
};
}
const rankedPlayers = Object.values(store.players || {})
.filter((entry) => String(entry?.playerKey || '').startsWith('identity:') && Number.isFinite(entry?.totalPoints) && entry.totalPoints > 0)
const rankedPlayers = listBarcodePlayers(store.players)
.sort((a, b) => (b.totalPoints || 0) - (a.totalPoints || 0));
const rank = rankedPlayers.findIndex((entry) => entry.playerKey === player.playerKey) + 1;
const effectivePlayer = {
...(player || {}),
...(featurePlayer || {}),
playerKey: player?.playerKey || playerKey || featurePlayer?.playerKeys?.[0],
nickname: player?.nickname || featurePlayer?.nickname || identity.nickname || null,
totalPoints: Math.max(Number(player?.totalPoints || 0), Number(featurePlayer?.totalPoints || 0)),
games: {
...(featurePlayer?.games || {}),
...(player?.games || {}),
},
};
const rank = rankedPlayers.findIndex((entry) => entry.playerKey === effectivePlayer.playerKey) + 1;
return {
playerKey: player.playerKey,
nickname: player.nickname || identity.nickname || null,
totalPoints: player.totalPoints || 0,
playerKey: effectivePlayer.playerKey,
nickname: effectivePlayer.nickname,
totalPoints: effectivePlayer.totalPoints || 0,
rank: rank > 0 ? rank : null,
games: player.games || {},
games: effectivePlayer.games || {},
};
}
@@ -1013,9 +1068,31 @@ function buildLifecycleGameState(store, { now, selectedDefinition, runningDefini
};
}
function topPlayers(players = {}, limit = 6) {
return Object.values(players || {})
function listBarcodePlayers(players = {}) {
const byKey = new Map();
Object.values(players || {})
.filter((player) => String(player?.playerKey || '').startsWith('identity:') && Number.isFinite(player?.totalPoints) && player.totalPoints > 0)
.forEach((player) => byKey.set(player.playerKey, player));
listFeatureStates('barcodeGames').forEach(({ userId, state }) => {
const playerKey = `identity:${userId}`;
const previous = byKey.get(playerKey) || {};
byKey.set(playerKey, {
...previous,
...state,
playerKey,
userId,
totalPoints: Math.max(Number(previous.totalPoints || 0), Number(state?.totalPoints || 0)),
nickname: previous.nickname || state?.nickname || null,
lastRoverId: previous.lastRoverId || state?.lastRoverId || null,
});
});
return Array.from(byKey.values()).filter((player) => Number.isFinite(player?.totalPoints) && player.totalPoints > 0);
}
function topPlayers(players = {}, limit = 6) {
return listBarcodePlayers(players)
.sort((a, b) => (b.totalPoints || 0) - (a.totalPoints || 0))
.slice(0, limit)
.map((player) => ({
@@ -84,6 +84,7 @@ function normalizePlayers(rawPlayers = {}) {
if (!key || !rawPlayer || typeof rawPlayer !== 'object') return;
players[key] = {
playerKey: typeof rawPlayer.playerKey === 'string' ? rawPlayer.playerKey : key,
userId: typeof rawPlayer.userId === 'string' ? rawPlayer.userId : null,
cookieUserId: typeof rawPlayer.cookieUserId === 'string' ? rawPlayer.cookieUserId : null,
nickname: typeof rawPlayer.nickname === 'string' ? rawPlayer.nickname : null,
lastRoverId: typeof rawPlayer.lastRoverId === 'string' ? rawPlayer.lastRoverId : null,
@@ -14,7 +14,7 @@ function createDeterCommand({ listDeterredUsers, listVerifiedUsers, deterUser, u
if (action === 'list') {
const users = listDeterredUsers();
if (!users.length) return message.reply({ content: 'No deterred users.', allowedMentions: { parse: [], repliedUser: false } });
const lines = users.map((entry, idx) => `${idx + 1}. ${entry.id} | ${entry.nickname || 'unknown'} | ${mask(entry.cookieUserId)}`);
const lines = users.map((entry, idx) => `${idx + 1}. ${entry.userId || entry.id} | ${entry.nickname || 'unknown'} | ${mask(entry.cookieUserId)}`);
return message.reply({ content: ['Deterred users:', ...lines].join('\n').slice(0, 1900), allowedMentions: { parse: [], repliedUser: false } });
}
if (action === 'ban') {
@@ -28,7 +28,7 @@ function createDeterCommand({ listDeterredUsers, listVerifiedUsers, deterUser, u
// Ban reasons were deliberately removed from the command grammar. The
// full remaining text is now always the selector, which lets lockdown
// admins deter multi-word nicknames without quoting or delimiter rules.
const stableSelector = verifiedMatch.record?.cookieUserId || selector;
const stableSelector = verifiedMatch.record?.userId || verifiedMatch.record?.id || verifiedMatch.record?.cookieUserId || selector;
const deterred = deterUser(stableSelector, { actor: message.author?.id || null });
return message.reply({ content: sanitizeMentions(`${deterred.created ? 'Deterred' : 'Updated deterrence for'} ${deterred.nickname || 'unknown'} (${mask(deterred.cookieUserId)}).`), allowedMentions: { parse: [], repliedUser: false } });
} catch (err) {
@@ -89,19 +89,25 @@ function resolveRoverSelector(selector, rovers) {
function createIdentityCandidates(records = [], { includeId = true } = {}) {
return (Array.isArray(records) ? records : []).map((record) => {
const id = normalizeText(record?.id);
const userId = normalizeText(record?.userId);
const cookieUserId = normalizeText(record?.cookieUserId);
const fingerprintId = normalizeText(record?.fingerprintId);
const nickname = normalizeText(record?.nickname);
const knownIps = Array.isArray(record?.knownIps) ? record.knownIps.map(normalizeText).filter(Boolean) : [];
return {
key: id || cookieUserId || nickname,
key: userId || id || cookieUserId || fingerprintId || nickname,
id,
userId,
cookieUserId,
fingerprintId,
nickname,
knownIps,
label: compactJoin([nickname || 'unknown', includeId && id ? id : '', cookieUserId ? mask(cookieUserId) : '']),
label: compactJoin([nickname || 'unknown', includeId && (userId || id) ? (userId || id) : '', cookieUserId ? mask(cookieUserId) : '']),
record,
searchId: normalizeSearchText(id),
searchUserId: normalizeSearchText(userId),
searchCookie: normalizeSearchText(cookieUserId),
searchFingerprint: normalizeSearchText(fingerprintId),
searchNickname: normalizeSearchText(nickname),
searchIps: knownIps.map(normalizeSearchText),
};
@@ -118,7 +124,9 @@ function resolveIdentitySelector(selector, records = [], options = {}) {
const exact = candidates.filter((entry) => (
entry.searchId === normalized ||
entry.searchUserId === normalized ||
entry.searchCookie === normalized ||
entry.searchFingerprint === normalized ||
entry.searchNickname === normalized ||
(ip && entry.searchIps.includes(normalizeSearchText(ip)))
));
@@ -132,6 +140,7 @@ function resolveIdentitySelector(selector, records = [], options = {}) {
keys: [
{ name: 'nickname', weight: 0.78 },
{ name: 'cookieUserId', weight: 0.12 },
{ name: 'fingerprintId', weight: 0.08 },
{ name: 'id', weight: 0.08 },
{ name: 'knownIps', weight: 0.02 },
],
@@ -13,7 +13,7 @@ function createVerifyCommand({ listVerifiedUsers, removeVerifiedUser, isLockdown
if (action === 'list') {
const users = listVerifiedUsers();
if (!users.length) return message.reply({ content: 'No verified users.', allowedMentions: { parse: [], repliedUser: false } });
const lines = users.map((entry, idx) => `${idx + 1}. ${entry.nickname || 'unknown'} | ${mask(entry.cookieUserId)}`);
const lines = users.map((entry, idx) => `${idx + 1}. ${entry.nickname || 'unknown'} | ${entry.userId || entry.id} | ${mask(entry.cookieUserId)}`);
return message.reply({ content: ['Verified users:', ...lines].join('\n').slice(0, 1900), allowedMentions: { parse: [], repliedUser: false } });
}
if (action === 'remove') {
@@ -26,7 +26,7 @@ function createVerifyCommand({ listVerifiedUsers, removeVerifiedUser, isLockdown
// The command resolver only turns a human-friendly or fuzzy nickname
// into the stable cookie id so the service does not need Discord/Web
// command concerns baked into its storage API.
const removed = removeVerifiedUser(resolved.record.cookieUserId, message.author?.id || null);
const removed = removeVerifiedUser(resolved.record.userId || resolved.record.id || resolved.record.cookieUserId, message.author?.id || null);
return message.reply({ content: `Removed verified user ${sanitizeMentions(removed.nickname || 'unknown')} (${mask(removed.cookieUserId)}).`, allowedMentions: { parse: [], repliedUser: false } });
} catch (err) {
return message.reply({ content: sanitizeMentions(`Failed to remove verified user: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } });
+790 -9
View File
@@ -1,11 +1,31 @@
// identity Service
// Purpose: Defines the identity Service module and the helpers/state used by this service unit.
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
// 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 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 = 1;
const identityEvents = new EventEmitter();
let db = null;
let dbFileExistedAtOpen = false;
function nowMs() {
return Date.now();
}
function sanitizeNickname(raw) {
if (typeof raw !== 'string') return '';
@@ -28,21 +48,757 @@ 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 createJsonStore({ path, normalizeStoreShape, cloneStore, logger }) {
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
);
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 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};
`);
}
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);
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);
}
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;
}
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),
};
}
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,
},
features,
};
}
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 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 isVerified(socket) {
return Boolean(socket?.data?.isVerified);
}
function isDeterred(socket) {
return Boolean(socket?.data?.isDeterred);
}
function listUsers({ verified = null, deterred = 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 (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,
};
}
function listVerifiedUsers() {
return listUsers({ verified: true }).map(userToLegacyIdentityEntry);
}
function listDeterredUsers() {
return listUsers({ deterred: 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(path, 'utf8');
const raw = fs.readFileSync(filePath, 'utf8');
cache = normalizeStoreShape(JSON.parse(raw));
} catch (err) {
if (err.code !== 'ENOENT') {
logger?.warn?.('Failed to load JSON store', { path, error: err.message });
storeLogger?.warn?.('Failed to load JSON store', { path: filePath, error: err.message });
}
cache = normalizeStoreShape({});
}
@@ -51,10 +807,10 @@ function createJsonStore({ path, normalizeStoreShape, cloneStore, logger }) {
function writeStore(next) {
const normalized = normalizeStoreShape(next);
fs.mkdirSync(require('path').dirname(path), { recursive: true });
const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
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, path);
fs.renameSync(tempPath, filePath);
cache = normalized;
return cache;
}
@@ -75,10 +831,35 @@ function createJsonStore({ path, normalizeStoreShape, cloneStore, logger }) {
}
module.exports = {
identityEvents,
getDb,
sanitizeNickname,
normalizeCookieUserId,
isValidCookieUserId,
generateCookieUserId,
normalizeFingerprintId,
isValidFingerprintId,
generateUserId,
getKnownIp,
identifySocket,
normalizeSocketIdentity,
resolveUserIdForIdentity,
attachIdentitySignals,
getUserById,
getUserForSocket,
getUserIdForSocket,
getIdentitySummary,
getFeatureState,
setFeatureState,
updateFeatureState,
listFeatureStates,
setVerified,
setDeterrence,
isVerified,
isDeterred,
listVerifiedUsers,
listDeterredUsers,
resolveUserBySelector,
userToLegacyIdentityEntry,
createJsonStore,
};
@@ -133,7 +133,7 @@ function buildVoteStatus() {
sockets.forEach((socket) => {
if (!isEligibleVoter(socket)) return;
const identityKey = String(socket?.data?.cookieUserId || '').trim() || `socket:${socket.id}`;
const identityKey = String(socket?.data?.userId || '').trim() || String(socket?.data?.cookieUserId || '').trim() || `socket:${socket.id}`;
const pref = typeof socket?.data?.overseerEnabled === 'boolean' ? socket.data.overseerEnabled : true;
const prev = votesByIdentity.get(identityKey);
if (typeof prev === 'boolean') {
@@ -9,7 +9,7 @@ const roverManager = require('../roverManager');
const { getNickname } = require('../nicknameService');
const { getRole, isLockdownAdmin } = require('../roleService');
const { getSocketIp, normalizeIp } = require('../../helpers/ipResolver');
const { normalizeCookieUserId } = require('../identityService');
const { getIdentitySummary, normalizeCookieUserId } = require('../identityService');
const {
REQUEST_COOLDOWN_MS,
GRANT_TTL_MS,
@@ -55,6 +55,7 @@ function pruneExpiredGrantsAndRefresh(reason = 'grant_expired') {
function listPendingForRequester(socket) {
const requesterKey = buildRequesterKey(socket);
const identity = getIdentitySummary(socket);
const pending = [];
for (const request of pendingRequests.values()) {
if (request.requesterKey !== requesterKey) continue;
@@ -333,7 +334,9 @@ function createRequest(socket, roverIdRaw) {
nickname: getNickname(socket) || null,
role: getRole(socket),
isVerified: Boolean(socket?.data?.isVerified),
userId: identity.userId || null,
cookieUserId: normalizeCookieUserId(socket?.data?.cookieUserId) || null,
fingerprintId: identity.fingerprintId || null,
ip: normalizeIp(getSocketIp(socket)) || null,
},
};
@@ -10,6 +10,8 @@ function normalizeRoverId(value) {
}
function buildRequesterKey(socket) {
const userId = String(socket?.data?.userId || '').trim();
if (userId) return `user:${userId}`;
const cookieUserId = normalizeCookieUserId(socket?.data?.cookieUserId);
if (cookieUserId) return `cookie:${cookieUserId}`;
return `socket:${socket?.id || 'unknown'}`;
@@ -44,6 +46,14 @@ function getSocketByRequesterKey(requesterKey) {
const socketId = requesterKey.slice('socket:'.length);
return io.sockets.sockets.get(socketId) || null;
}
if (requesterKey.startsWith('user:')) {
const userId = requesterKey.slice('user:'.length);
for (const socket of io.sockets.sockets.values()) {
if (String(socket?.data?.userId || '').trim() === userId) {
return socket;
}
}
}
if (requesterKey.startsWith('cookie:')) {
const cookieUserId = requesterKey.slice('cookie:'.length);
for (const socket of io.sockets.sockets.values()) {
@@ -61,6 +61,7 @@ function buildUserEntry(socket) {
const primaryRover = roverManager.getPrimaryRoverForSocket(socket.id);
return {
socketId: socket.id,
userId: socket?.data?.userId || null,
nickname: getNickname(socket) || null,
role,
roverId: primaryRover || assignment?.roverId || null,
+433 -163
View File
@@ -1,31 +1,35 @@
// Verification Service Module
// Purpose: Composes verification identity, request, and moderation deterrence flows into one public service API.
// Scope: Exposes stable verification operations while delegating behavior to focused submodules.
// Purpose: Binds socket.IO verification/moderation events to the central identity service.
// Scope: Keeps verification workflow behavior stable while moving user equality to identityService.
const EventEmitter = require('events');
const crypto = require('crypto');
const io = require('../../globals/io');
const logger = require('../../globals/logger').child('verificationService');
const { publishEvent } = require('../eventBus');
const { getNickname, setNickname } = require('../nicknameService');
const { getRole, roleEvents } = require('../roleService');
const { loadStore, withStore } = require('./store');
const {
ensureSocketData,
identityFromSocket,
normalizeNicknameKey,
normalizeKnownIps,
isAdminRole,
parseDeterrenceSelector,
isRawIp,
getDb,
identifySocket: identifyCanonicalSocket,
getUserById,
getUserForSocket,
getUserIdForSocket,
getIdentitySummary,
getKnownIp,
normalizeCookieUserId,
isValidCookieUserId,
generateCookieUserId,
normalizeFingerprintId,
isValidFingerprintId,
resolveUserIdForIdentity,
attachIdentitySignals,
sanitizeNickname,
} = require('./identity');
const { createVerificationFlow } = require('./verificationFlow');
const { createDeterrenceFlow } = require('./deterrenceFlow');
const { createRequestFlow } = require('./requestFlow');
const { registerVerificationHooks } = require('./hooks');
setVerified,
setDeterrence,
listVerifiedUsers,
listDeterredUsers,
resolveUserBySelector,
userToLegacyIdentityEntry,
} = require('../identityService');
const verificationEvents = new EventEmitter();
const IDENTITY_TIMEOUT_MS = 2 * 60 * 1000;
@@ -36,72 +40,34 @@ function emitChange(reason, payload = {}) {
verificationEvents.emit('change', { reason, ...payload });
}
let reevaluateSocketVerification = () => ({ isVerified: false, matchedRecordId: null, reason: 'not_initialized' });
let reevaluateSocketDeterrence = () => ({ isDeterred: false, matchedRecordId: null, reason: 'not_initialized' });
function isAdminRole(role) {
return role === 'admin' || role === 'lockdown' || role === 'lockdown-admin';
}
const verificationFlow = createVerificationFlow({
loadStore,
withStore,
io,
publishEvent,
emitChange,
getRole,
getNickname,
ensureSocketData,
identityFromSocket,
normalizeCookieUserId,
sanitizeNickname,
reevaluateSocketDeterrence: (...args) => reevaluateSocketDeterrence(...args),
});
reevaluateSocketVerification = verificationFlow.reevaluateSocketVerification;
function refreshSocketIdentityFlags(socket) {
const user = getUserForSocket(socket);
if (!socket?.data || !user) {
return { isVerified: false, isDeterred: false, matchedRecordId: null, reason: 'no_user' };
}
const deterrenceFlow = createDeterrenceFlow({
loadStore,
withStore,
io,
publishEvent,
emitChange,
getRole,
ensureSocketData,
identityFromSocket,
normalizeNicknameKey,
normalizeKnownIps,
isAdminRole,
parseDeterrenceSelector,
isRawIp,
normalizeCookieUserId,
isValidCookieUserId,
sanitizeNickname,
findVerifiedMatch: verificationFlow.findVerifiedMatch,
});
reevaluateSocketDeterrence = deterrenceFlow.reevaluateSocketDeterrence;
const requestFlow = createRequestFlow({
loadStore,
withStore,
io,
publishEvent,
emitChange,
ensureSocketData,
identityFromSocket,
isValidCookieUserId,
normalizeCookieUserId,
reevaluateSocketVerification,
reevaluateSocketDeterrence,
});
const role = getRole(socket);
const verifiedByRole = isAdminRole(role);
const deterredByUser = Boolean(user.deterrence?.enabled);
socket.data.isVerified = verifiedByRole || Boolean(user.verified?.enabled);
socket.data.verifiedRecordId = socket.data.isVerified ? user.id : null;
socket.data.isDeterred = isAdminRole(role) ? false : deterredByUser;
socket.data.deterredRecordId = socket.data.isDeterred ? user.id : null;
return {
isVerified: socket.data.isVerified,
isDeterred: socket.data.isDeterred,
matchedRecordId: user.id,
reason: socket.data.isVerified ? 'matched' : 'no_match',
userId: user.id,
};
}
function identifySocket(socket, payload = {}) {
if (!socket) {
throw new Error('Socket required');
}
const data = ensureSocketData(socket);
const incomingKey = normalizeCookieUserId(payload.cookieUserId);
if (incomingKey && !isValidCookieUserId(incomingKey)) {
throw new Error('Invalid identity key format.');
}
const currentKey = normalizeCookieUserId(data.cookieUserId);
const safeCurrentKey = isValidCookieUserId(currentKey) ? currentKey : '';
data.cookieUserId = incomingKey || safeCurrentKey || generateCookieUserId();
if (!socket) throw new Error('Socket required');
const incomingNickname = sanitizeNickname(payload.nickname);
if (incomingNickname) {
@@ -109,49 +75,50 @@ function identifySocket(socket, payload = {}) {
if (incomingNickname !== getNickname(socket)) {
setNickname(socket, incomingNickname);
}
socket.data = socket.data || {};
socket.data.nickname = incomingNickname;
} catch (err) {
logger.warn('Failed to set nickname from identify', err.message);
}
}
if (Object.prototype.hasOwnProperty.call(payload, 'overseerEnabled')) {
data.overseerEnabled = Boolean(payload.overseerEnabled);
} else if (typeof data.overseerEnabled !== 'boolean') {
data.overseerEnabled = true;
socket.data = socket.data || {};
socket.data.overseerEnabled = Boolean(payload.overseerEnabled);
} else if (typeof socket?.data?.overseerEnabled !== 'boolean') {
socket.data = socket.data || {};
socket.data.overseerEnabled = true;
}
/*
Spectator-style pages send the same identity heartbeat as the driver page,
but they should not participate in multitabbing prevention. The page surface
flag makes that distinction explicit before role changes finish, which avoids
a race where a spectator route briefly looks like a normal user connection.
Spectator-style pages send identity heartbeats too, but only the driver
surface participates in duplicate-driver enforcement. This flag remains on
the socket because it is connection-specific, not person-specific.
*/
data.identitySurface = payload.identitySurface === 'driver' ? 'driver' : 'passive';
socket.data.identitySurface = payload.identitySurface === 'driver' ? 'driver' : 'passive';
const verification = reevaluateSocketVerification(socket);
const deterrence = reevaluateSocketDeterrence(socket);
const result = identifyCanonicalSocket(socket, {
...payload,
nickname: incomingNickname || getNickname(socket) || '',
});
refreshSocketIdentityFlags(socket);
enforceSingleUnverifiedSocketPerIdentity(socket);
emitChange('identify', { socketId: socket.id });
emitChange('identify', { socketId: socket.id, userId: result.userId });
return {
cookieUserId: data.cookieUserId,
overseerEnabled: Boolean(data.overseerEnabled),
isVerified: verification.isVerified,
isDeterred: deterrence.isDeterred,
reason: verification.reason,
cookieUserId: result.cookieUserId,
fingerprintId: result.fingerprintId,
userId: result.userId,
overseerEnabled: Boolean(socket.data.overseerEnabled),
isVerified: Boolean(socket.data.isVerified),
isDeterred: Boolean(socket.data.isDeterred),
reason: socket.data.isVerified ? 'matched' : 'no_match',
identifiedAt: Date.now(),
};
}
function emitDuplicateIdentityAndDisconnect(socket, payload = {}) {
if (!socket?.id || socket.disconnected) return;
/*
The browser needs a small amount of time to render the blocking overlay
before the transport closes. Socket.IO does not guarantee that an immediate
disconnect after emit will be visible to the client, so the short timer is
intentionally used as an event-delivery grace period rather than a retry or
background worker.
*/
socket.emit('session:duplicateIdentity', {
reason: 'duplicate_identity',
message: 'This driver session is already active in another tab.',
@@ -165,82 +132,385 @@ function emitDuplicateIdentityAndDisconnect(socket, payload = {}) {
}
function enforceSingleUnverifiedSocketPerIdentity(currentSocket) {
const currentKey = normalizeCookieUserId(currentSocket?.data?.cookieUserId);
const currentUserId = getUserIdForSocket(currentSocket);
if (
!currentSocket?.id ||
!currentKey ||
!currentUserId ||
currentSocket.data?.isVerified ||
currentSocket.data?.identitySurface !== 'driver'
) {
return;
}
/*
Verification status is evaluated before this function runs. That ordering is
important because verified users are immune to duplicate-tab enforcement:
a verified socket is never disconnected here, and a verified current socket
never causes older tabs to be removed.
*/
const duplicates = Array.from(io.sockets.sockets.values()).filter((candidate) => {
if (!candidate?.id || candidate.id === currentSocket.id || candidate.disconnected) return false;
if (candidate?.data?.identitySurface !== 'driver') return false;
const candidateKey = normalizeCookieUserId(candidate?.data?.cookieUserId);
return Boolean(candidateKey && candidateKey === currentKey);
return getUserIdForSocket(candidate) === currentUserId;
});
if (duplicates.length === 0) {
return;
}
if (!duplicates.length) return;
const verifiedDuplicate = duplicates.find((candidate) => candidate?.data?.isVerified);
if (verifiedDuplicate) {
/*
A verified tab is allowed to keep running, but the non-verified tab that
collided with it should still be blocked. This keeps the immunity attached
to verified users instead of turning a verified identity key into a bypass
for unverified browser sessions.
*/
logger.info('Disconnecting non-verified socket because its identity is already active on a verified socket', {
logger.info('Disconnecting non-verified socket because its user is already active on a verified socket', {
socketId: currentSocket.id,
retainedSocketId: verifiedDuplicate.id,
cookieUserId: currentKey,
});
emitDuplicateIdentityAndDisconnect(currentSocket, {
retainedSocketId: verifiedDuplicate.id,
userId: currentUserId,
});
emitDuplicateIdentityAndDisconnect(currentSocket, { retainedSocketId: verifiedDuplicate.id });
return;
}
/*
When all duplicates are non-verified, the newest socket wins. Opening a new
tab should move the user to that tab instead of leaving an older background
tab with rover control, chat identity, or game participation.
*/
duplicates.forEach((duplicate) => {
logger.info('Disconnecting older non-verified duplicate identity socket', {
logger.info('Disconnecting older non-verified duplicate user socket', {
socketId: duplicate.id,
retainedSocketId: currentSocket.id,
cookieUserId: currentKey,
});
emitDuplicateIdentityAndDisconnect(duplicate, {
retainedSocketId: currentSocket.id,
userId: currentUserId,
});
emitDuplicateIdentityAndDisconnect(duplicate, { retainedSocketId: currentSocket.id });
});
}
function getPendingRequestForUser(userId) {
if (!userId) return null;
return getDb().prepare(`
select * from verification_requests
where user_id = ? and status = 'pending'
order by created_at desc
limit 1
`).get(userId) || null;
}
function getVerificationStateForSocket(socket) {
return verificationFlow.getVerificationStateForSocket(socket, requestFlow.getPendingRequestForIdentity);
const userId = getUserIdForSocket(socket);
const pending = getPendingRequestForUser(userId);
return {
isVerified: Boolean(socket?.data?.isVerified),
pendingRequestId: pending?.id || null,
pendingRequestedAt: pending?.created_at || null,
};
}
registerVerificationHooks({
io,
roleEvents,
logger,
identifySocket,
createVerificationRequest: requestFlow.createVerificationRequest,
reevaluateSocketVerification,
reevaluateSocketDeterrence,
emitChange,
function getModerationStateForSocket(socket) {
return {
isDeterred: Boolean(socket?.data?.isDeterred),
recordId: socket?.data?.deterredRecordId || null,
};
}
function createVerificationRequest(socket) {
if (!socket) throw new Error('Socket required');
refreshSocketIdentityFlags(socket);
const user = getUserForSocket(socket);
if (!user) throw new Error('Identity missing. Reconnect and try again.');
if (socket.data?.isVerified) throw new Error('You are already verified.');
const cookieUserId = normalizeCookieUserId(socket.data?.cookieUserId);
if (!cookieUserId || !isValidCookieUserId(cookieUserId)) {
throw new Error('Identity key format invalid.');
}
const existingPending = getPendingRequestForUser(user.id);
if (existingPending) {
return {
id: existingPending.id,
status: existingPending.status,
cookieUserId: existingPending.cookie_user_id,
userId: existingPending.user_id,
};
}
const request = {
id: `vr_${crypto.randomBytes(8).toString('hex')}`,
status: 'pending',
userId: user.id,
cookieUserId,
fingerprintId: normalizeFingerprintId(socket.data?.fingerprintId) || null,
nickname: getNickname(socket) || user.nickname || null,
ip: getKnownIp(socket) || null,
socketId: socket.id,
createdAt: Date.now(),
};
getDb().prepare(`
insert into verification_requests
(id, user_id, cookie_user_id, fingerprint_id, nickname, ip, socket_id, status, decision, created_at)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
request.id,
request.userId,
request.cookieUserId,
request.fingerprintId,
request.nickname,
request.ip,
request.socketId,
request.status,
null,
request.createdAt,
);
publishEvent({ source: 'verification', type: 'verification.requested', payload: request });
emitChange('request', { requestId: request.id, socketId: socket.id, userId: user.id });
return request;
}
function attachDmMessage(requestId, messageId, adminDiscordId) {
if (!requestId || !messageId) return;
getDb().prepare(`
insert or ignore into verification_dm_messages (message_id, request_id, admin_discord_id, created_at)
values (?, ?, ?, ?)
`).run(String(messageId), String(requestId), adminDiscordId ? String(adminDiscordId) : null, Date.now());
}
function rowToRequest(row) {
if (!row) return null;
return {
id: row.id,
userId: row.user_id,
cookieUserId: row.cookie_user_id,
fingerprintId: row.fingerprint_id,
nickname: row.nickname,
ip: row.ip,
socketId: row.socket_id,
status: row.status,
decision: row.decision,
createdAt: row.created_at,
resolvedAt: row.resolved_at,
resolvedBy: row.resolved_by,
};
}
function getPendingRequestById(requestId) {
if (!requestId) return null;
return rowToRequest(getDb().prepare(`
select * from verification_requests
where id = ? and status = 'pending'
`).get(String(requestId)));
}
function getRequestByMessageId(messageId) {
if (!messageId) return null;
const map = getDb().prepare('select * from verification_dm_messages where message_id = ?').get(String(messageId));
if (!map) return null;
const request = rowToRequest(getDb().prepare('select * from verification_requests where id = ?').get(map.request_id));
return request ? {
request,
map: {
requestId: map.request_id,
messageId: map.message_id,
adminDiscordId: map.admin_discord_id,
createdAt: map.created_at,
},
} : null;
}
function refreshSocketsForUser(userId) {
io.sockets.sockets.forEach((socket) => {
if (getUserIdForSocket(socket) !== userId) return;
refreshSocketIdentityFlags(socket);
emitChange('user_refresh', { socketId: socket.id, userId });
});
}
function approveRequest(requestId, actorDiscordId) {
const request = getPendingRequestById(requestId);
if (!request) throw new Error('Request not found or already resolved.');
const resolvedAt = Date.now();
const actor = actorDiscordId ? String(actorDiscordId) : null;
getDb().prepare(`
update verification_requests
set status = 'approved', decision = 'approved', resolved_at = ?, resolved_by = ?
where id = ? and status = 'pending'
`).run(resolvedAt, actor, requestId);
setVerified(request.userId, { enabled: true, actor, at: resolvedAt });
refreshSocketsForUser(request.userId);
publishEvent({
source: 'verification',
type: 'verification.resolved',
payload: {
requestId,
decision: 'approved',
userId: request.userId,
cookieUserId: request.cookieUserId,
nickname: request.nickname,
resolvedBy: actor,
resolvedAt,
},
});
emitChange('approve', { requestId, userId: request.userId });
}
function denyRequest(requestId, actorDiscordId) {
const request = getPendingRequestById(requestId);
if (!request) throw new Error('Request not found or already resolved.');
const resolvedAt = Date.now();
const actor = actorDiscordId ? String(actorDiscordId) : null;
getDb().prepare(`
update verification_requests
set status = 'denied', decision = 'denied', resolved_at = ?, resolved_by = ?
where id = ? and status = 'pending'
`).run(resolvedAt, actor, requestId);
publishEvent({
source: 'verification',
type: 'verification.resolved',
payload: {
requestId,
decision: 'denied',
userId: request.userId,
cookieUserId: request.cookieUserId,
nickname: request.nickname,
resolvedBy: actor,
resolvedAt,
},
});
emitChange('deny', { requestId, userId: request.userId });
}
function removeVerifiedUser(selector, removedBy = null) {
const resolved = resolveUserBySelector(selector, { includeVerified: true, includeDeterred: false });
if (resolved.error || !resolved.user) {
throw new Error(resolved.error === 'ambiguous_nickname' ? 'Nickname matches multiple users.' : 'Verified user not found.');
}
const removed = setVerified(resolved.user.id, { enabled: false, actor: removedBy || null });
refreshSocketsForUser(resolved.user.id);
emitChange('remove', { userId: resolved.user.id });
publishEvent({
source: 'verification',
type: 'verification.userRemoved',
payload: {
userId: removed.id,
cookieUserId: removed.cookieUserIds[0] || null,
nickname: removed.nickname,
removedBy,
removedAt: Date.now(),
},
});
return userToLegacyIdentityEntry(removed);
}
function deterUser(selector, options = {}) {
const rawSelector = String(selector || '').trim();
if (!rawSelector) throw new Error('Selector required.');
let resolved = resolveUserBySelector(rawSelector, { includeVerified: true, includeDeterred: true });
if (resolved.error === 'not_found') {
const cookie = normalizeCookieUserId(rawSelector);
const fingerprint = normalizeFingerprintId(rawSelector);
if (cookie && isValidCookieUserId(cookie)) {
const userId = resolveUserIdForIdentity({ cookieUserId: cookie }, { create: true });
resolved = { user: attachIdentitySignals(userId, { cookieUserId: cookie }) };
} else if (fingerprint && isValidFingerprintId(fingerprint)) {
const userId = resolveUserIdForIdentity({ fingerprintId: fingerprint }, { create: true });
resolved = { user: attachIdentitySignals(userId, { fingerprintId: fingerprint }) };
}
}
if (resolved.error || !resolved.user) {
throw new Error(resolved.error === 'ambiguous_nickname' ? 'Nickname matches multiple users.' : 'User not found.');
}
const wasDeterred = Boolean(resolved.user.deterrence?.enabled);
const user = setDeterrence(resolved.user.id, {
enabled: true,
reason: String(options?.reason || '').trim() || null,
actor: options?.actor ? String(options.actor) : null,
at: Date.now(),
});
refreshSocketsForUser(user.id);
publishEvent({
source: 'moderation',
type: wasDeterred ? 'moderation.deterrenceUpdated' : 'moderation.deterred',
payload: {
id: user.id,
userId: user.id,
cookieUserId: user.cookieUserIds[0] || null,
fingerprintId: user.fingerprintIds[0] || null,
nickname: user.nickname,
knownIps: user.knownIps,
reason: user.deterrence.reason,
actor: options?.actor ? String(options.actor) : null,
ts: Date.now(),
},
});
emitChange('deter_update', { userId: user.id });
return { ...userToLegacyIdentityEntry(user), created: !wasDeterred };
}
function undeterUser(selector, removedBy = null) {
const resolved = resolveUserBySelector(selector, { includeVerified: true, includeDeterred: true });
if (resolved.error || !resolved.user || !resolved.user.deterrence?.enabled) {
throw new Error(resolved.error === 'ambiguous_nickname' ? 'Nickname matches multiple users.' : 'Deterred user not found.');
}
const user = setDeterrence(resolved.user.id, { enabled: false, actor: removedBy || null });
refreshSocketsForUser(user.id);
const removedAt = Date.now();
emitChange('deter_remove', { userId: user.id });
publishEvent({
source: 'moderation',
type: 'moderation.undeterred',
payload: {
id: user.id,
userId: user.id,
cookieUserId: user.cookieUserIds[0] || null,
nickname: user.nickname,
removedBy: removedBy ? String(removedBy) : null,
removedAt,
},
});
return userToLegacyIdentityEntry(user);
}
function reevaluateSocketVerification(socket) {
return refreshSocketIdentityFlags(socket);
}
function reevaluateSocketDeterrence(socket) {
return refreshSocketIdentityFlags(socket);
}
function getVerificationStatus(socket) {
return {
isVerified: Boolean(socket?.data?.isVerified),
recordId: socket?.data?.verifiedRecordId || null,
};
}
io.on('connection', (socket) => {
socket.data = socket.data || {};
socket.data.connectedAt = Date.now();
identifySocket(socket, {});
socket.on('session:identify', (payload = {}, cb = () => {}) => {
try {
const result = identifySocket(socket, payload || {});
socket.data.lastClientIdentifyAt = Date.now();
cb({ success: true, ...result });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('verification:request', (_, cb = () => {}) => {
try {
const request = createVerificationRequest(socket);
cb({ success: true, requestId: request.id, status: request.status });
} catch (err) {
cb({ error: err.message });
}
});
});
roleEvents.on('change', ({ socket }) => {
if (!socket) return;
try {
refreshSocketIdentityFlags(socket);
emitChange('role_change', { socketId: socket.id, userId: getUserIdForSocket(socket) });
} catch (err) {
logger.warn('Failed to reevaluate verification on role change', err.message);
}
});
setInterval(() => {
@@ -266,22 +536,22 @@ setInterval(() => {
module.exports = {
identifySocket,
getVerificationStatus: verificationFlow.getVerificationStatus,
getIdentitySummary: verificationFlow.getIdentitySummary,
getVerificationStatus,
getIdentitySummary,
getVerificationStateForSocket,
getModerationStateForSocket: deterrenceFlow.getModerationStateForSocket,
createVerificationRequest: requestFlow.createVerificationRequest,
attachDmMessage: requestFlow.attachDmMessage,
getRequestByMessageId: requestFlow.getRequestByMessageId,
approveRequest: requestFlow.approveRequest,
denyRequest: requestFlow.denyRequest,
listVerifiedUsers: verificationFlow.listVerifiedUsers,
removeVerifiedUser: verificationFlow.removeVerifiedUser,
listDeterredUsers: deterrenceFlow.listDeterredUsers,
deterUser: deterrenceFlow.deterUser,
undeterUser: deterrenceFlow.undeterUser,
isVerified: verificationFlow.isVerified,
isDeterred: deterrenceFlow.isDeterred,
getModerationStateForSocket,
createVerificationRequest,
attachDmMessage,
getRequestByMessageId,
approveRequest,
denyRequest,
listVerifiedUsers,
removeVerifiedUser,
listDeterredUsers,
deterUser,
undeterUser,
isVerified: (socket) => Boolean(socket?.data?.isVerified),
isDeterred: (socket) => Boolean(socket?.data?.isDeterred),
reevaluateSocketVerification,
reevaluateSocketDeterrence,
verificationEvents,
+7
View File
@@ -8,6 +8,7 @@
"name": "webui",
"version": "0.0.0",
"dependencies": {
"@thumbmarkjs/thumbmarkjs": "^1.10.0",
"midi-file": "^1.2.4",
"react": "^19.2.0",
"react-dom": "^19.2.0",
@@ -1417,6 +1418,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@thumbmarkjs/thumbmarkjs": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@thumbmarkjs/thumbmarkjs/-/thumbmarkjs-1.10.0.tgz",
"integrity": "sha512-mmIjivwI76Jm1to/VsEzxX+FNRD38HBHV37LmlF6Gg6psclifkVO01KV7UZr+i2d9IboqsTcW33vD1vr3meCeA==",
"license": "MIT"
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+1
View File
@@ -10,6 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"@thumbmarkjs/thumbmarkjs": "^1.10.0",
"midi-file": "^1.2.4",
"react": "^19.2.0",
"react-dom": "^19.2.0",
+2 -2
View File
@@ -337,8 +337,8 @@ export function SessionProvider({ children }) {
const actions = useMemo(
() => ({
login: (username, password) => emitWithAck('auth:login', { username, password }),
identifySession: ({ cookieUserId, nickname, overseerEnabled, identitySurface } = {}) =>
emitWithAck('session:identify', { cookieUserId, nickname, overseerEnabled, identitySurface }),
identifySession: ({ cookieUserId, fingerprintId, nickname, overseerEnabled, identitySurface } = {}) =>
emitWithAck('session:identify', { cookieUserId, fingerprintId, nickname, overseerEnabled, identitySurface }),
setRole: (role) => emitWithAck('session:setRole', { role }),
requestControl: (roverId, options = {}) =>
emitWithAck('session:requestControl', { roverId, ...options }),
+11
View File
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useRef } from 'react';
import { useSessionActions, useSessionSelector } from '../context/SessionContext.jsx';
import { useSocket } from '../context/SocketContext.jsx';
import { getBrowserFingerprintId } from '../lib/browserFingerprint.js';
import { useSettingsNamespace } from '../settings/index.js';
export default function useUserIdentitySync({ identitySurface = 'passive' } = {}) {
@@ -21,6 +22,7 @@ export default function useUserIdentitySync({ identitySurface = 'passive' } = {}
const inFlightRef = useRef(false);
const lastAckSocketRef = useRef(null);
const retryTimerRef = useRef(null);
const fingerprintRef = useRef('');
const ready =
identityStatus === 'ready' && profileStatus === 'ready' && overseerPreferenceStatus === 'ready';
@@ -40,6 +42,14 @@ export default function useUserIdentitySync({ identitySurface = 'passive' } = {}
if (!ready || !connected || !socket?.id || inFlightRef.current) return;
inFlightRef.current = true;
try {
if (!fingerprintRef.current) {
/*
The portable cookie key is still the cross-device identity signal.
Thumbmark adds a same-device signal that survives cookie clearing, so
both are sent together whenever the heartbeat identifies this socket.
*/
fingerprintRef.current = await getBrowserFingerprintId();
}
/*
Every route shares the same persisted identity key, but only the main
driver page should trigger duplicate-tab prevention. Sending the surface
@@ -48,6 +58,7 @@ export default function useUserIdentitySync({ identitySurface = 'passive' } = {}
*/
const resp = await identifySession({
cookieUserId,
fingerprintId: fingerprintRef.current,
nickname,
overseerEnabled,
identitySurface: normalizedIdentitySurface,
+34
View File
@@ -0,0 +1,34 @@
// Browser Fingerprint
// Purpose: Wraps Thumbmark behind one app-owned helper so identity sync does not
// depend on the third-party package shape throughout the UI.
// Scope: Produces a normalized device fingerprint signal for the server identity service.
import { getFingerprint } from '@thumbmarkjs/thumbmarkjs';
let fingerprintPromise = null;
function normalizeThumbmark(value) {
const raw = String(value || '').trim().toLowerCase();
if (!raw) return '';
/*
The server treats the prefix as part of the signal format so different
fingerprint providers can coexist later without hash-space ambiguity.
*/
const body = raw.startsWith('tm_') ? raw.slice(3) : raw;
const safeBody = body.replace(/[^a-z0-9_-]/g, '');
return safeBody ? `tm_${safeBody}` : '';
}
export async function getBrowserFingerprintId() {
if (typeof window === 'undefined') return '';
if (!fingerprintPromise) {
fingerprintPromise = Promise.resolve()
.then(() => getFingerprint())
.then(normalizeThumbmark)
.catch((error) => {
console.warn('Failed to calculate browser fingerprint', error); // eslint-disable-line no-console
return '';
});
}
return fingerprintPromise;
}