mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
here goes nothin'
This commit is contained in:
@@ -17,6 +17,7 @@ server/data/discord-guilds.json
|
|||||||
server/data/global-objective.json
|
server/data/global-objective.json
|
||||||
server/data/admin-reason.json
|
server/data/admin-reason.json
|
||||||
server/data/buttonbox-state.json
|
server/data/buttonbox-state.json
|
||||||
|
server/data/barcode-tts-cache/
|
||||||
webui/package-lock.json
|
webui/package-lock.json
|
||||||
!server/data/
|
!server/data/
|
||||||
!server/data/barcode-registry.json
|
!server/data/barcode-registry.json
|
||||||
@@ -24,3 +25,8 @@ webui/src/config/analytics.jsx
|
|||||||
webui/src/config/driverAnalytics.json
|
webui/src/config/driverAnalytics.json
|
||||||
webui/src/config/analytics.html
|
webui/src/config/analytics.html
|
||||||
plans/barcodegames.txt
|
plans/barcodegames.txt
|
||||||
|
.gitignore
|
||||||
|
server/data/identity.sqlite
|
||||||
|
server/data/barcode-games.json
|
||||||
|
server/data/identity.sqlite-shm
|
||||||
|
server/data/identity.sqlite-wal
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
|
"better-sqlite3": "^12.11.1",
|
||||||
"discord.js": "^14.25.1",
|
"discord.js": "^14.25.1",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"fuse.js": "^7.4.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
@@ -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/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>
|
<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>
|
<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">
|
<link rel="stylesheet" crossorigin href="/assets/index-CKlOAshP.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const { subscribe } = require('../eventBus');
|
|||||||
const { sendSystemMessage } = require('../chatService');
|
const { sendSystemMessage } = require('../chatService');
|
||||||
const { getActiveDrivers } = require('../turnService');
|
const { getActiveDrivers } = require('../turnService');
|
||||||
const { getIdentitySummary } = require('../verificationService');
|
const { getIdentitySummary } = require('../verificationService');
|
||||||
|
const { getFeatureState, listFeatureStates, updateFeatureState } = require('../identityService');
|
||||||
const { getRegistrySnapshot } = require('../barcodeScannerService');
|
const { getRegistrySnapshot } = require('../barcodeScannerService');
|
||||||
const { loadStore, withGameStore } = require('./store');
|
const { loadStore, withGameStore } = require('./store');
|
||||||
const scanQuest = require('./games/scanQuest');
|
const scanQuest = require('./games/scanQuest');
|
||||||
@@ -241,7 +242,7 @@ function getKnownObjects() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizePlayerKey(identity = {}, socketId = '', roverId = '') {
|
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 (socketId) return `socket:${socketId}`;
|
||||||
if (roverId) return `rover:${roverId}`;
|
if (roverId) return `rover:${roverId}`;
|
||||||
return null;
|
return null;
|
||||||
@@ -250,7 +251,7 @@ function normalizePlayerKey(identity = {}, socketId = '', roverId = '') {
|
|||||||
function normalizeIdentityPlayerKey(identity = {}) {
|
function normalizeIdentityPlayerKey(identity = {}) {
|
||||||
// Permanent scoring is identity-only. Socket and rover IDs are useful runtime
|
// Permanent scoring is identity-only. Socket and rover IDs are useful runtime
|
||||||
// evidence, but they are unstable and should not create leaderboard entries.
|
// 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) {
|
function resolveRoverParticipant(roverId) {
|
||||||
@@ -271,6 +272,7 @@ function resolveRoverParticipant(roverId) {
|
|||||||
playerKey,
|
playerKey,
|
||||||
roverId: normalizedRoverId,
|
roverId: normalizedRoverId,
|
||||||
socketId,
|
socketId,
|
||||||
|
userId: identity.userId || null,
|
||||||
cookieUserId: identity.cookieUserId || null,
|
cookieUserId: identity.cookieUserId || null,
|
||||||
nickname: identity.nickname || normalizedRoverId,
|
nickname: identity.nickname || normalizedRoverId,
|
||||||
};
|
};
|
||||||
@@ -312,6 +314,7 @@ function recordRoundParticipant(draft, participant, now) {
|
|||||||
playerKey: participant.playerKey || previous.playerKey || null,
|
playerKey: participant.playerKey || previous.playerKey || null,
|
||||||
roverId: participant.roverId || previous.roverId || null,
|
roverId: participant.roverId || previous.roverId || null,
|
||||||
socketId: participant.socketId || previous.socketId || null,
|
socketId: participant.socketId || previous.socketId || null,
|
||||||
|
userId: participant.userId || previous.userId || null,
|
||||||
cookieUserId: participant.cookieUserId || previous.cookieUserId || null,
|
cookieUserId: participant.cookieUserId || previous.cookieUserId || null,
|
||||||
nickname: participant.nickname || previous.nickname || participant.roverId || 'unknown player',
|
nickname: participant.nickname || previous.nickname || participant.roverId || 'unknown player',
|
||||||
joinedAt: Number.isFinite(previous.joinedAt) ? previous.joinedAt : now,
|
joinedAt: Number.isFinite(previous.joinedAt) ? previous.joinedAt : now,
|
||||||
@@ -332,6 +335,7 @@ function getRoundParticipants(draft) {
|
|||||||
playerKey: participant.playerKey || null,
|
playerKey: participant.playerKey || null,
|
||||||
roverId: participant.roverId || null,
|
roverId: participant.roverId || null,
|
||||||
socketId: participant.socketId || null,
|
socketId: participant.socketId || null,
|
||||||
|
userId: participant.userId || null,
|
||||||
cookieUserId: participant.cookieUserId || null,
|
cookieUserId: participant.cookieUserId || null,
|
||||||
nickname: participant.nickname || participant.roverId || 'unknown player',
|
nickname: participant.nickname || participant.roverId || 'unknown player',
|
||||||
joinedAt: Number.isFinite(participant.joinedAt) ? participant.joinedAt : null,
|
joinedAt: Number.isFinite(participant.joinedAt) ? participant.joinedAt : null,
|
||||||
@@ -349,6 +353,7 @@ function getProximityParticipants(draft, now) {
|
|||||||
playerKey: sighting.playerKey,
|
playerKey: sighting.playerKey,
|
||||||
roverId: sighting.roverId,
|
roverId: sighting.roverId,
|
||||||
socketId: sighting.socketId || null,
|
socketId: sighting.socketId || null,
|
||||||
|
userId: sighting.userId || null,
|
||||||
cookieUserId: sighting.cookieUserId || null,
|
cookieUserId: sighting.cookieUserId || null,
|
||||||
nickname: sighting.nickname || sighting.roverId,
|
nickname: sighting.nickname || sighting.roverId,
|
||||||
scannedAt: sighting.scannedAt,
|
scannedAt: sighting.scannedAt,
|
||||||
@@ -407,6 +412,7 @@ function recordPlayerParticipation(draft, gameId, participants, now) {
|
|||||||
const previousGame = previousGames[gameId] || {};
|
const previousGame = previousGames[gameId] || {};
|
||||||
draft.players[participant.playerKey] = {
|
draft.players[participant.playerKey] = {
|
||||||
playerKey: participant.playerKey,
|
playerKey: participant.playerKey,
|
||||||
|
userId: participant.userId || previous.userId || null,
|
||||||
cookieUserId: participant.cookieUserId || previous.cookieUserId || null,
|
cookieUserId: participant.cookieUserId || previous.cookieUserId || null,
|
||||||
nickname: participant.nickname || previous.nickname || null,
|
nickname: participant.nickname || previous.nickname || null,
|
||||||
lastRoverId: participant.roverId || previous.lastRoverId || 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) {
|
function applyPointAwards(draft, gameId, awards = [], now) {
|
||||||
if (!gameId || !Array.isArray(awards) || !awards.length) return;
|
if (!gameId || !Array.isArray(awards) || !awards.length) return;
|
||||||
draft.players = draft.players || {};
|
draft.players = draft.players || {};
|
||||||
@@ -443,6 +454,7 @@ function applyPointAwards(draft, gameId, awards = [], now) {
|
|||||||
// one persistent place.
|
// one persistent place.
|
||||||
draft.players[playerKey] = {
|
draft.players[playerKey] = {
|
||||||
playerKey,
|
playerKey,
|
||||||
|
userId: award.userId || previous.userId || userIdFromPlayerKey(playerKey),
|
||||||
cookieUserId: award.cookieUserId || previous.cookieUserId || null,
|
cookieUserId: award.cookieUserId || previous.cookieUserId || null,
|
||||||
nickname: award.nickname || previous.nickname || null,
|
nickname: award.nickname || previous.nickname || null,
|
||||||
lastRoverId: award.roverId || previous.lastRoverId || 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, {
|
addRecentEvent(draft, {
|
||||||
kind: 'pointsAwarded',
|
kind: 'pointsAwarded',
|
||||||
gameId,
|
gameId,
|
||||||
@@ -816,8 +860,9 @@ function getPlayerForSocket(store, socket) {
|
|||||||
if (!socket) return null;
|
if (!socket) return null;
|
||||||
const identity = getIdentitySummary(socket);
|
const identity = getIdentitySummary(socket);
|
||||||
const playerKey = normalizeIdentityPlayerKey(identity);
|
const playerKey = normalizeIdentityPlayerKey(identity);
|
||||||
|
const featurePlayer = identity.userId ? getFeatureState(identity.userId, 'barcodeGames', null) : null;
|
||||||
const player = playerKey ? store.players?.[playerKey] || null : null;
|
const player = playerKey ? store.players?.[playerKey] || null : null;
|
||||||
if (!player) {
|
if (!player && !featurePlayer) {
|
||||||
return {
|
return {
|
||||||
playerKey,
|
playerKey,
|
||||||
nickname: identity.nickname || null,
|
nickname: identity.nickname || null,
|
||||||
@@ -826,16 +871,26 @@ function getPlayerForSocket(store, socket) {
|
|||||||
games: {},
|
games: {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const rankedPlayers = Object.values(store.players || {})
|
const rankedPlayers = listBarcodePlayers(store.players)
|
||||||
.filter((entry) => String(entry?.playerKey || '').startsWith('identity:') && Number.isFinite(entry?.totalPoints) && entry.totalPoints > 0)
|
|
||||||
.sort((a, b) => (b.totalPoints || 0) - (a.totalPoints || 0));
|
.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 {
|
return {
|
||||||
playerKey: player.playerKey,
|
playerKey: effectivePlayer.playerKey,
|
||||||
nickname: player.nickname || identity.nickname || null,
|
nickname: effectivePlayer.nickname,
|
||||||
totalPoints: player.totalPoints || 0,
|
totalPoints: effectivePlayer.totalPoints || 0,
|
||||||
rank: rank > 0 ? rank : null,
|
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) {
|
function listBarcodePlayers(players = {}) {
|
||||||
return Object.values(players || {})
|
const byKey = new Map();
|
||||||
|
Object.values(players || {})
|
||||||
.filter((player) => String(player?.playerKey || '').startsWith('identity:') && Number.isFinite(player?.totalPoints) && player.totalPoints > 0)
|
.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))
|
.sort((a, b) => (b.totalPoints || 0) - (a.totalPoints || 0))
|
||||||
.slice(0, limit)
|
.slice(0, limit)
|
||||||
.map((player) => ({
|
.map((player) => ({
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ function normalizePlayers(rawPlayers = {}) {
|
|||||||
if (!key || !rawPlayer || typeof rawPlayer !== 'object') return;
|
if (!key || !rawPlayer || typeof rawPlayer !== 'object') return;
|
||||||
players[key] = {
|
players[key] = {
|
||||||
playerKey: typeof rawPlayer.playerKey === 'string' ? rawPlayer.playerKey : 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,
|
cookieUserId: typeof rawPlayer.cookieUserId === 'string' ? rawPlayer.cookieUserId : null,
|
||||||
nickname: typeof rawPlayer.nickname === 'string' ? rawPlayer.nickname : null,
|
nickname: typeof rawPlayer.nickname === 'string' ? rawPlayer.nickname : null,
|
||||||
lastRoverId: typeof rawPlayer.lastRoverId === 'string' ? rawPlayer.lastRoverId : null,
|
lastRoverId: typeof rawPlayer.lastRoverId === 'string' ? rawPlayer.lastRoverId : null,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ function createDeterCommand({ listDeterredUsers, listVerifiedUsers, deterUser, u
|
|||||||
if (action === 'list') {
|
if (action === 'list') {
|
||||||
const users = listDeterredUsers();
|
const users = listDeterredUsers();
|
||||||
if (!users.length) return message.reply({ content: 'No deterred users.', allowedMentions: { parse: [], repliedUser: false } });
|
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 } });
|
return message.reply({ content: ['Deterred users:', ...lines].join('\n').slice(0, 1900), allowedMentions: { parse: [], repliedUser: false } });
|
||||||
}
|
}
|
||||||
if (action === 'ban') {
|
if (action === 'ban') {
|
||||||
@@ -28,7 +28,7 @@ function createDeterCommand({ listDeterredUsers, listVerifiedUsers, deterUser, u
|
|||||||
// Ban reasons were deliberately removed from the command grammar. The
|
// Ban reasons were deliberately removed from the command grammar. The
|
||||||
// full remaining text is now always the selector, which lets lockdown
|
// full remaining text is now always the selector, which lets lockdown
|
||||||
// admins deter multi-word nicknames without quoting or delimiter rules.
|
// 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 });
|
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 } });
|
return message.reply({ content: sanitizeMentions(`${deterred.created ? 'Deterred' : 'Updated deterrence for'} ${deterred.nickname || 'unknown'} (${mask(deterred.cookieUserId)}).`), allowedMentions: { parse: [], repliedUser: false } });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -89,19 +89,25 @@ function resolveRoverSelector(selector, rovers) {
|
|||||||
function createIdentityCandidates(records = [], { includeId = true } = {}) {
|
function createIdentityCandidates(records = [], { includeId = true } = {}) {
|
||||||
return (Array.isArray(records) ? records : []).map((record) => {
|
return (Array.isArray(records) ? records : []).map((record) => {
|
||||||
const id = normalizeText(record?.id);
|
const id = normalizeText(record?.id);
|
||||||
|
const userId = normalizeText(record?.userId);
|
||||||
const cookieUserId = normalizeText(record?.cookieUserId);
|
const cookieUserId = normalizeText(record?.cookieUserId);
|
||||||
|
const fingerprintId = normalizeText(record?.fingerprintId);
|
||||||
const nickname = normalizeText(record?.nickname);
|
const nickname = normalizeText(record?.nickname);
|
||||||
const knownIps = Array.isArray(record?.knownIps) ? record.knownIps.map(normalizeText).filter(Boolean) : [];
|
const knownIps = Array.isArray(record?.knownIps) ? record.knownIps.map(normalizeText).filter(Boolean) : [];
|
||||||
return {
|
return {
|
||||||
key: id || cookieUserId || nickname,
|
key: userId || id || cookieUserId || fingerprintId || nickname,
|
||||||
id,
|
id,
|
||||||
|
userId,
|
||||||
cookieUserId,
|
cookieUserId,
|
||||||
|
fingerprintId,
|
||||||
nickname,
|
nickname,
|
||||||
knownIps,
|
knownIps,
|
||||||
label: compactJoin([nickname || 'unknown', includeId && id ? id : '', cookieUserId ? mask(cookieUserId) : '']),
|
label: compactJoin([nickname || 'unknown', includeId && (userId || id) ? (userId || id) : '', cookieUserId ? mask(cookieUserId) : '']),
|
||||||
record,
|
record,
|
||||||
searchId: normalizeSearchText(id),
|
searchId: normalizeSearchText(id),
|
||||||
|
searchUserId: normalizeSearchText(userId),
|
||||||
searchCookie: normalizeSearchText(cookieUserId),
|
searchCookie: normalizeSearchText(cookieUserId),
|
||||||
|
searchFingerprint: normalizeSearchText(fingerprintId),
|
||||||
searchNickname: normalizeSearchText(nickname),
|
searchNickname: normalizeSearchText(nickname),
|
||||||
searchIps: knownIps.map(normalizeSearchText),
|
searchIps: knownIps.map(normalizeSearchText),
|
||||||
};
|
};
|
||||||
@@ -118,7 +124,9 @@ function resolveIdentitySelector(selector, records = [], options = {}) {
|
|||||||
|
|
||||||
const exact = candidates.filter((entry) => (
|
const exact = candidates.filter((entry) => (
|
||||||
entry.searchId === normalized ||
|
entry.searchId === normalized ||
|
||||||
|
entry.searchUserId === normalized ||
|
||||||
entry.searchCookie === normalized ||
|
entry.searchCookie === normalized ||
|
||||||
|
entry.searchFingerprint === normalized ||
|
||||||
entry.searchNickname === normalized ||
|
entry.searchNickname === normalized ||
|
||||||
(ip && entry.searchIps.includes(normalizeSearchText(ip)))
|
(ip && entry.searchIps.includes(normalizeSearchText(ip)))
|
||||||
));
|
));
|
||||||
@@ -132,6 +140,7 @@ function resolveIdentitySelector(selector, records = [], options = {}) {
|
|||||||
keys: [
|
keys: [
|
||||||
{ name: 'nickname', weight: 0.78 },
|
{ name: 'nickname', weight: 0.78 },
|
||||||
{ name: 'cookieUserId', weight: 0.12 },
|
{ name: 'cookieUserId', weight: 0.12 },
|
||||||
|
{ name: 'fingerprintId', weight: 0.08 },
|
||||||
{ name: 'id', weight: 0.08 },
|
{ name: 'id', weight: 0.08 },
|
||||||
{ name: 'knownIps', weight: 0.02 },
|
{ name: 'knownIps', weight: 0.02 },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ function createVerifyCommand({ listVerifiedUsers, removeVerifiedUser, isLockdown
|
|||||||
if (action === 'list') {
|
if (action === 'list') {
|
||||||
const users = listVerifiedUsers();
|
const users = listVerifiedUsers();
|
||||||
if (!users.length) return message.reply({ content: 'No verified users.', allowedMentions: { parse: [], repliedUser: false } });
|
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 } });
|
return message.reply({ content: ['Verified users:', ...lines].join('\n').slice(0, 1900), allowedMentions: { parse: [], repliedUser: false } });
|
||||||
}
|
}
|
||||||
if (action === 'remove') {
|
if (action === 'remove') {
|
||||||
@@ -26,7 +26,7 @@ function createVerifyCommand({ listVerifiedUsers, removeVerifiedUser, isLockdown
|
|||||||
// The command resolver only turns a human-friendly or fuzzy nickname
|
// The command resolver only turns a human-friendly or fuzzy nickname
|
||||||
// into the stable cookie id so the service does not need Discord/Web
|
// into the stable cookie id so the service does not need Discord/Web
|
||||||
// command concerns baked into its storage API.
|
// 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 } });
|
return message.reply({ content: `Removed verified user ${sanitizeMentions(removed.nickname || 'unknown')} (${mask(removed.cookieUserId)}).`, allowedMentions: { parse: [], repliedUser: false } });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return message.reply({ content: sanitizeMentions(`Failed to remove verified user: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } });
|
return message.reply({ content: sanitizeMentions(`Failed to remove verified user: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } });
|
||||||
|
|||||||
@@ -1,11 +1,31 @@
|
|||||||
// identity Service
|
// Identity Service
|
||||||
// Purpose: Defines the identity Service module and the helpers/state used by this service unit.
|
// Purpose: Owns canonical user identity, strong identity signals, and per-user feature state.
|
||||||
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
|
// 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 fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
const EventEmitter = require('events');
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
const { getSocketIp, normalizeIp } = require('../../helpers/ipResolver');
|
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 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) {
|
function sanitizeNickname(raw) {
|
||||||
if (typeof raw !== 'string') return '';
|
if (typeof raw !== 'string') return '';
|
||||||
@@ -28,21 +48,757 @@ function generateCookieUserId() {
|
|||||||
return `cu_${crypto.randomBytes(16).toString('hex')}`;
|
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) {
|
function getKnownIp(socket) {
|
||||||
return normalizeIp(getSocketIp(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;
|
let cache = null;
|
||||||
|
|
||||||
function loadStore() {
|
function loadStore() {
|
||||||
if (cache) return cache;
|
if (cache) return cache;
|
||||||
try {
|
try {
|
||||||
const raw = fs.readFileSync(path, 'utf8');
|
const raw = fs.readFileSync(filePath, 'utf8');
|
||||||
cache = normalizeStoreShape(JSON.parse(raw));
|
cache = normalizeStoreShape(JSON.parse(raw));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.code !== 'ENOENT') {
|
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({});
|
cache = normalizeStoreShape({});
|
||||||
}
|
}
|
||||||
@@ -51,10 +807,10 @@ function createJsonStore({ path, normalizeStoreShape, cloneStore, logger }) {
|
|||||||
|
|
||||||
function writeStore(next) {
|
function writeStore(next) {
|
||||||
const normalized = normalizeStoreShape(next);
|
const normalized = normalizeStoreShape(next);
|
||||||
fs.mkdirSync(require('path').dirname(path), { recursive: true });
|
fs.mkdirSync(require('path').dirname(filePath), { recursive: true });
|
||||||
const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
|
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
||||||
fs.writeFileSync(tempPath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8');
|
fs.writeFileSync(tempPath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8');
|
||||||
fs.renameSync(tempPath, path);
|
fs.renameSync(tempPath, filePath);
|
||||||
cache = normalized;
|
cache = normalized;
|
||||||
return cache;
|
return cache;
|
||||||
}
|
}
|
||||||
@@ -75,10 +831,35 @@ function createJsonStore({ path, normalizeStoreShape, cloneStore, logger }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
identityEvents,
|
||||||
|
getDb,
|
||||||
sanitizeNickname,
|
sanitizeNickname,
|
||||||
normalizeCookieUserId,
|
normalizeCookieUserId,
|
||||||
isValidCookieUserId,
|
isValidCookieUserId,
|
||||||
generateCookieUserId,
|
generateCookieUserId,
|
||||||
|
normalizeFingerprintId,
|
||||||
|
isValidFingerprintId,
|
||||||
|
generateUserId,
|
||||||
getKnownIp,
|
getKnownIp,
|
||||||
|
identifySocket,
|
||||||
|
normalizeSocketIdentity,
|
||||||
|
resolveUserIdForIdentity,
|
||||||
|
attachIdentitySignals,
|
||||||
|
getUserById,
|
||||||
|
getUserForSocket,
|
||||||
|
getUserIdForSocket,
|
||||||
|
getIdentitySummary,
|
||||||
|
getFeatureState,
|
||||||
|
setFeatureState,
|
||||||
|
updateFeatureState,
|
||||||
|
listFeatureStates,
|
||||||
|
setVerified,
|
||||||
|
setDeterrence,
|
||||||
|
isVerified,
|
||||||
|
isDeterred,
|
||||||
|
listVerifiedUsers,
|
||||||
|
listDeterredUsers,
|
||||||
|
resolveUserBySelector,
|
||||||
|
userToLegacyIdentityEntry,
|
||||||
createJsonStore,
|
createJsonStore,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ function buildVoteStatus() {
|
|||||||
|
|
||||||
sockets.forEach((socket) => {
|
sockets.forEach((socket) => {
|
||||||
if (!isEligibleVoter(socket)) return;
|
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 pref = typeof socket?.data?.overseerEnabled === 'boolean' ? socket.data.overseerEnabled : true;
|
||||||
const prev = votesByIdentity.get(identityKey);
|
const prev = votesByIdentity.get(identityKey);
|
||||||
if (typeof prev === 'boolean') {
|
if (typeof prev === 'boolean') {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const roverManager = require('../roverManager');
|
|||||||
const { getNickname } = require('../nicknameService');
|
const { getNickname } = require('../nicknameService');
|
||||||
const { getRole, isLockdownAdmin } = require('../roleService');
|
const { getRole, isLockdownAdmin } = require('../roleService');
|
||||||
const { getSocketIp, normalizeIp } = require('../../helpers/ipResolver');
|
const { getSocketIp, normalizeIp } = require('../../helpers/ipResolver');
|
||||||
const { normalizeCookieUserId } = require('../identityService');
|
const { getIdentitySummary, normalizeCookieUserId } = require('../identityService');
|
||||||
const {
|
const {
|
||||||
REQUEST_COOLDOWN_MS,
|
REQUEST_COOLDOWN_MS,
|
||||||
GRANT_TTL_MS,
|
GRANT_TTL_MS,
|
||||||
@@ -55,6 +55,7 @@ function pruneExpiredGrantsAndRefresh(reason = 'grant_expired') {
|
|||||||
|
|
||||||
function listPendingForRequester(socket) {
|
function listPendingForRequester(socket) {
|
||||||
const requesterKey = buildRequesterKey(socket);
|
const requesterKey = buildRequesterKey(socket);
|
||||||
|
const identity = getIdentitySummary(socket);
|
||||||
const pending = [];
|
const pending = [];
|
||||||
for (const request of pendingRequests.values()) {
|
for (const request of pendingRequests.values()) {
|
||||||
if (request.requesterKey !== requesterKey) continue;
|
if (request.requesterKey !== requesterKey) continue;
|
||||||
@@ -333,7 +334,9 @@ function createRequest(socket, roverIdRaw) {
|
|||||||
nickname: getNickname(socket) || null,
|
nickname: getNickname(socket) || null,
|
||||||
role: getRole(socket),
|
role: getRole(socket),
|
||||||
isVerified: Boolean(socket?.data?.isVerified),
|
isVerified: Boolean(socket?.data?.isVerified),
|
||||||
|
userId: identity.userId || null,
|
||||||
cookieUserId: normalizeCookieUserId(socket?.data?.cookieUserId) || null,
|
cookieUserId: normalizeCookieUserId(socket?.data?.cookieUserId) || null,
|
||||||
|
fingerprintId: identity.fingerprintId || null,
|
||||||
ip: normalizeIp(getSocketIp(socket)) || null,
|
ip: normalizeIp(getSocketIp(socket)) || null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ function normalizeRoverId(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildRequesterKey(socket) {
|
function buildRequesterKey(socket) {
|
||||||
|
const userId = String(socket?.data?.userId || '').trim();
|
||||||
|
if (userId) return `user:${userId}`;
|
||||||
const cookieUserId = normalizeCookieUserId(socket?.data?.cookieUserId);
|
const cookieUserId = normalizeCookieUserId(socket?.data?.cookieUserId);
|
||||||
if (cookieUserId) return `cookie:${cookieUserId}`;
|
if (cookieUserId) return `cookie:${cookieUserId}`;
|
||||||
return `socket:${socket?.id || 'unknown'}`;
|
return `socket:${socket?.id || 'unknown'}`;
|
||||||
@@ -44,6 +46,14 @@ function getSocketByRequesterKey(requesterKey) {
|
|||||||
const socketId = requesterKey.slice('socket:'.length);
|
const socketId = requesterKey.slice('socket:'.length);
|
||||||
return io.sockets.sockets.get(socketId) || null;
|
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:')) {
|
if (requesterKey.startsWith('cookie:')) {
|
||||||
const cookieUserId = requesterKey.slice('cookie:'.length);
|
const cookieUserId = requesterKey.slice('cookie:'.length);
|
||||||
for (const socket of io.sockets.sockets.values()) {
|
for (const socket of io.sockets.sockets.values()) {
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ function buildUserEntry(socket) {
|
|||||||
const primaryRover = roverManager.getPrimaryRoverForSocket(socket.id);
|
const primaryRover = roverManager.getPrimaryRoverForSocket(socket.id);
|
||||||
return {
|
return {
|
||||||
socketId: socket.id,
|
socketId: socket.id,
|
||||||
|
userId: socket?.data?.userId || null,
|
||||||
nickname: getNickname(socket) || null,
|
nickname: getNickname(socket) || null,
|
||||||
role,
|
role,
|
||||||
roverId: primaryRover || assignment?.roverId || null,
|
roverId: primaryRover || assignment?.roverId || null,
|
||||||
|
|||||||
@@ -1,31 +1,35 @@
|
|||||||
// Verification Service Module
|
// Verification Service Module
|
||||||
// Purpose: Composes verification identity, request, and moderation deterrence flows into one public service API.
|
// Purpose: Binds socket.IO verification/moderation events to the central identity service.
|
||||||
// Scope: Exposes stable verification operations while delegating behavior to focused submodules.
|
// Scope: Keeps verification workflow behavior stable while moving user equality to identityService.
|
||||||
const EventEmitter = require('events');
|
const EventEmitter = require('events');
|
||||||
|
const crypto = require('crypto');
|
||||||
const io = require('../../globals/io');
|
const io = require('../../globals/io');
|
||||||
const logger = require('../../globals/logger').child('verificationService');
|
const logger = require('../../globals/logger').child('verificationService');
|
||||||
const { publishEvent } = require('../eventBus');
|
const { publishEvent } = require('../eventBus');
|
||||||
const { getNickname, setNickname } = require('../nicknameService');
|
const { getNickname, setNickname } = require('../nicknameService');
|
||||||
const { getRole, roleEvents } = require('../roleService');
|
const { getRole, roleEvents } = require('../roleService');
|
||||||
|
|
||||||
const { loadStore, withStore } = require('./store');
|
|
||||||
const {
|
const {
|
||||||
ensureSocketData,
|
getDb,
|
||||||
identityFromSocket,
|
identifySocket: identifyCanonicalSocket,
|
||||||
normalizeNicknameKey,
|
getUserById,
|
||||||
normalizeKnownIps,
|
getUserForSocket,
|
||||||
isAdminRole,
|
getUserIdForSocket,
|
||||||
parseDeterrenceSelector,
|
getIdentitySummary,
|
||||||
isRawIp,
|
getKnownIp,
|
||||||
normalizeCookieUserId,
|
normalizeCookieUserId,
|
||||||
isValidCookieUserId,
|
isValidCookieUserId,
|
||||||
generateCookieUserId,
|
normalizeFingerprintId,
|
||||||
|
isValidFingerprintId,
|
||||||
|
resolveUserIdForIdentity,
|
||||||
|
attachIdentitySignals,
|
||||||
sanitizeNickname,
|
sanitizeNickname,
|
||||||
} = require('./identity');
|
setVerified,
|
||||||
const { createVerificationFlow } = require('./verificationFlow');
|
setDeterrence,
|
||||||
const { createDeterrenceFlow } = require('./deterrenceFlow');
|
listVerifiedUsers,
|
||||||
const { createRequestFlow } = require('./requestFlow');
|
listDeterredUsers,
|
||||||
const { registerVerificationHooks } = require('./hooks');
|
resolveUserBySelector,
|
||||||
|
userToLegacyIdentityEntry,
|
||||||
|
} = require('../identityService');
|
||||||
|
|
||||||
const verificationEvents = new EventEmitter();
|
const verificationEvents = new EventEmitter();
|
||||||
const IDENTITY_TIMEOUT_MS = 2 * 60 * 1000;
|
const IDENTITY_TIMEOUT_MS = 2 * 60 * 1000;
|
||||||
@@ -36,72 +40,34 @@ function emitChange(reason, payload = {}) {
|
|||||||
verificationEvents.emit('change', { reason, ...payload });
|
verificationEvents.emit('change', { reason, ...payload });
|
||||||
}
|
}
|
||||||
|
|
||||||
let reevaluateSocketVerification = () => ({ isVerified: false, matchedRecordId: null, reason: 'not_initialized' });
|
function isAdminRole(role) {
|
||||||
let reevaluateSocketDeterrence = () => ({ isDeterred: false, matchedRecordId: null, reason: 'not_initialized' });
|
return role === 'admin' || role === 'lockdown' || role === 'lockdown-admin';
|
||||||
|
}
|
||||||
|
|
||||||
const verificationFlow = createVerificationFlow({
|
function refreshSocketIdentityFlags(socket) {
|
||||||
loadStore,
|
const user = getUserForSocket(socket);
|
||||||
withStore,
|
if (!socket?.data || !user) {
|
||||||
io,
|
return { isVerified: false, isDeterred: false, matchedRecordId: null, reason: 'no_user' };
|
||||||
publishEvent,
|
}
|
||||||
emitChange,
|
|
||||||
getRole,
|
|
||||||
getNickname,
|
|
||||||
ensureSocketData,
|
|
||||||
identityFromSocket,
|
|
||||||
normalizeCookieUserId,
|
|
||||||
sanitizeNickname,
|
|
||||||
reevaluateSocketDeterrence: (...args) => reevaluateSocketDeterrence(...args),
|
|
||||||
});
|
|
||||||
reevaluateSocketVerification = verificationFlow.reevaluateSocketVerification;
|
|
||||||
|
|
||||||
const deterrenceFlow = createDeterrenceFlow({
|
const role = getRole(socket);
|
||||||
loadStore,
|
const verifiedByRole = isAdminRole(role);
|
||||||
withStore,
|
const deterredByUser = Boolean(user.deterrence?.enabled);
|
||||||
io,
|
socket.data.isVerified = verifiedByRole || Boolean(user.verified?.enabled);
|
||||||
publishEvent,
|
socket.data.verifiedRecordId = socket.data.isVerified ? user.id : null;
|
||||||
emitChange,
|
socket.data.isDeterred = isAdminRole(role) ? false : deterredByUser;
|
||||||
getRole,
|
socket.data.deterredRecordId = socket.data.isDeterred ? user.id : null;
|
||||||
ensureSocketData,
|
return {
|
||||||
identityFromSocket,
|
isVerified: socket.data.isVerified,
|
||||||
normalizeNicknameKey,
|
isDeterred: socket.data.isDeterred,
|
||||||
normalizeKnownIps,
|
matchedRecordId: user.id,
|
||||||
isAdminRole,
|
reason: socket.data.isVerified ? 'matched' : 'no_match',
|
||||||
parseDeterrenceSelector,
|
userId: user.id,
|
||||||
isRawIp,
|
};
|
||||||
normalizeCookieUserId,
|
}
|
||||||
isValidCookieUserId,
|
|
||||||
sanitizeNickname,
|
|
||||||
findVerifiedMatch: verificationFlow.findVerifiedMatch,
|
|
||||||
});
|
|
||||||
reevaluateSocketDeterrence = deterrenceFlow.reevaluateSocketDeterrence;
|
|
||||||
|
|
||||||
const requestFlow = createRequestFlow({
|
|
||||||
loadStore,
|
|
||||||
withStore,
|
|
||||||
io,
|
|
||||||
publishEvent,
|
|
||||||
emitChange,
|
|
||||||
ensureSocketData,
|
|
||||||
identityFromSocket,
|
|
||||||
isValidCookieUserId,
|
|
||||||
normalizeCookieUserId,
|
|
||||||
reevaluateSocketVerification,
|
|
||||||
reevaluateSocketDeterrence,
|
|
||||||
});
|
|
||||||
|
|
||||||
function identifySocket(socket, payload = {}) {
|
function identifySocket(socket, payload = {}) {
|
||||||
if (!socket) {
|
if (!socket) throw new Error('Socket required');
|
||||||
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();
|
|
||||||
|
|
||||||
const incomingNickname = sanitizeNickname(payload.nickname);
|
const incomingNickname = sanitizeNickname(payload.nickname);
|
||||||
if (incomingNickname) {
|
if (incomingNickname) {
|
||||||
@@ -109,49 +75,50 @@ function identifySocket(socket, payload = {}) {
|
|||||||
if (incomingNickname !== getNickname(socket)) {
|
if (incomingNickname !== getNickname(socket)) {
|
||||||
setNickname(socket, incomingNickname);
|
setNickname(socket, incomingNickname);
|
||||||
}
|
}
|
||||||
|
socket.data = socket.data || {};
|
||||||
|
socket.data.nickname = incomingNickname;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn('Failed to set nickname from identify', err.message);
|
logger.warn('Failed to set nickname from identify', err.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.prototype.hasOwnProperty.call(payload, 'overseerEnabled')) {
|
if (Object.prototype.hasOwnProperty.call(payload, 'overseerEnabled')) {
|
||||||
data.overseerEnabled = Boolean(payload.overseerEnabled);
|
socket.data = socket.data || {};
|
||||||
} else if (typeof data.overseerEnabled !== 'boolean') {
|
socket.data.overseerEnabled = Boolean(payload.overseerEnabled);
|
||||||
data.overseerEnabled = true;
|
} 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,
|
Spectator-style pages send identity heartbeats too, but only the driver
|
||||||
but they should not participate in multitabbing prevention. The page surface
|
surface participates in duplicate-driver enforcement. This flag remains on
|
||||||
flag makes that distinction explicit before role changes finish, which avoids
|
the socket because it is connection-specific, not person-specific.
|
||||||
a race where a spectator route briefly looks like a normal user connection.
|
|
||||||
*/
|
*/
|
||||||
data.identitySurface = payload.identitySurface === 'driver' ? 'driver' : 'passive';
|
socket.data.identitySurface = payload.identitySurface === 'driver' ? 'driver' : 'passive';
|
||||||
|
|
||||||
const verification = reevaluateSocketVerification(socket);
|
const result = identifyCanonicalSocket(socket, {
|
||||||
const deterrence = reevaluateSocketDeterrence(socket);
|
...payload,
|
||||||
|
nickname: incomingNickname || getNickname(socket) || '',
|
||||||
|
});
|
||||||
|
refreshSocketIdentityFlags(socket);
|
||||||
enforceSingleUnverifiedSocketPerIdentity(socket);
|
enforceSingleUnverifiedSocketPerIdentity(socket);
|
||||||
emitChange('identify', { socketId: socket.id });
|
emitChange('identify', { socketId: socket.id, userId: result.userId });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cookieUserId: data.cookieUserId,
|
cookieUserId: result.cookieUserId,
|
||||||
overseerEnabled: Boolean(data.overseerEnabled),
|
fingerprintId: result.fingerprintId,
|
||||||
isVerified: verification.isVerified,
|
userId: result.userId,
|
||||||
isDeterred: deterrence.isDeterred,
|
overseerEnabled: Boolean(socket.data.overseerEnabled),
|
||||||
reason: verification.reason,
|
isVerified: Boolean(socket.data.isVerified),
|
||||||
|
isDeterred: Boolean(socket.data.isDeterred),
|
||||||
|
reason: socket.data.isVerified ? 'matched' : 'no_match',
|
||||||
identifiedAt: Date.now(),
|
identifiedAt: Date.now(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitDuplicateIdentityAndDisconnect(socket, payload = {}) {
|
function emitDuplicateIdentityAndDisconnect(socket, payload = {}) {
|
||||||
if (!socket?.id || socket.disconnected) return;
|
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', {
|
socket.emit('session:duplicateIdentity', {
|
||||||
reason: 'duplicate_identity',
|
reason: 'duplicate_identity',
|
||||||
message: 'This driver session is already active in another tab.',
|
message: 'This driver session is already active in another tab.',
|
||||||
@@ -165,82 +132,385 @@ function emitDuplicateIdentityAndDisconnect(socket, payload = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function enforceSingleUnverifiedSocketPerIdentity(currentSocket) {
|
function enforceSingleUnverifiedSocketPerIdentity(currentSocket) {
|
||||||
const currentKey = normalizeCookieUserId(currentSocket?.data?.cookieUserId);
|
const currentUserId = getUserIdForSocket(currentSocket);
|
||||||
if (
|
if (
|
||||||
!currentSocket?.id ||
|
!currentSocket?.id ||
|
||||||
!currentKey ||
|
!currentUserId ||
|
||||||
currentSocket.data?.isVerified ||
|
currentSocket.data?.isVerified ||
|
||||||
currentSocket.data?.identitySurface !== 'driver'
|
currentSocket.data?.identitySurface !== 'driver'
|
||||||
) {
|
) {
|
||||||
return;
|
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) => {
|
const duplicates = Array.from(io.sockets.sockets.values()).filter((candidate) => {
|
||||||
if (!candidate?.id || candidate.id === currentSocket.id || candidate.disconnected) return false;
|
if (!candidate?.id || candidate.id === currentSocket.id || candidate.disconnected) return false;
|
||||||
if (candidate?.data?.identitySurface !== 'driver') return false;
|
if (candidate?.data?.identitySurface !== 'driver') return false;
|
||||||
const candidateKey = normalizeCookieUserId(candidate?.data?.cookieUserId);
|
return getUserIdForSocket(candidate) === currentUserId;
|
||||||
return Boolean(candidateKey && candidateKey === currentKey);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (duplicates.length === 0) {
|
if (!duplicates.length) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const verifiedDuplicate = duplicates.find((candidate) => candidate?.data?.isVerified);
|
const verifiedDuplicate = duplicates.find((candidate) => candidate?.data?.isVerified);
|
||||||
if (verifiedDuplicate) {
|
if (verifiedDuplicate) {
|
||||||
/*
|
logger.info('Disconnecting non-verified socket because its user is already active on a verified socket', {
|
||||||
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', {
|
|
||||||
socketId: currentSocket.id,
|
socketId: currentSocket.id,
|
||||||
retainedSocketId: verifiedDuplicate.id,
|
retainedSocketId: verifiedDuplicate.id,
|
||||||
cookieUserId: currentKey,
|
userId: currentUserId,
|
||||||
});
|
|
||||||
emitDuplicateIdentityAndDisconnect(currentSocket, {
|
|
||||||
retainedSocketId: verifiedDuplicate.id,
|
|
||||||
});
|
});
|
||||||
|
emitDuplicateIdentityAndDisconnect(currentSocket, { retainedSocketId: verifiedDuplicate.id });
|
||||||
return;
|
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) => {
|
duplicates.forEach((duplicate) => {
|
||||||
logger.info('Disconnecting older non-verified duplicate identity socket', {
|
logger.info('Disconnecting older non-verified duplicate user socket', {
|
||||||
socketId: duplicate.id,
|
socketId: duplicate.id,
|
||||||
retainedSocketId: currentSocket.id,
|
retainedSocketId: currentSocket.id,
|
||||||
cookieUserId: currentKey,
|
userId: currentUserId,
|
||||||
});
|
|
||||||
emitDuplicateIdentityAndDisconnect(duplicate, {
|
|
||||||
retainedSocketId: currentSocket.id,
|
|
||||||
});
|
});
|
||||||
|
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) {
|
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({
|
function getModerationStateForSocket(socket) {
|
||||||
io,
|
return {
|
||||||
roleEvents,
|
isDeterred: Boolean(socket?.data?.isDeterred),
|
||||||
logger,
|
recordId: socket?.data?.deterredRecordId || null,
|
||||||
identifySocket,
|
};
|
||||||
createVerificationRequest: requestFlow.createVerificationRequest,
|
}
|
||||||
reevaluateSocketVerification,
|
|
||||||
reevaluateSocketDeterrence,
|
function createVerificationRequest(socket) {
|
||||||
emitChange,
|
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(() => {
|
setInterval(() => {
|
||||||
@@ -266,22 +536,22 @@ setInterval(() => {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
identifySocket,
|
identifySocket,
|
||||||
getVerificationStatus: verificationFlow.getVerificationStatus,
|
getVerificationStatus,
|
||||||
getIdentitySummary: verificationFlow.getIdentitySummary,
|
getIdentitySummary,
|
||||||
getVerificationStateForSocket,
|
getVerificationStateForSocket,
|
||||||
getModerationStateForSocket: deterrenceFlow.getModerationStateForSocket,
|
getModerationStateForSocket,
|
||||||
createVerificationRequest: requestFlow.createVerificationRequest,
|
createVerificationRequest,
|
||||||
attachDmMessage: requestFlow.attachDmMessage,
|
attachDmMessage,
|
||||||
getRequestByMessageId: requestFlow.getRequestByMessageId,
|
getRequestByMessageId,
|
||||||
approveRequest: requestFlow.approveRequest,
|
approveRequest,
|
||||||
denyRequest: requestFlow.denyRequest,
|
denyRequest,
|
||||||
listVerifiedUsers: verificationFlow.listVerifiedUsers,
|
listVerifiedUsers,
|
||||||
removeVerifiedUser: verificationFlow.removeVerifiedUser,
|
removeVerifiedUser,
|
||||||
listDeterredUsers: deterrenceFlow.listDeterredUsers,
|
listDeterredUsers,
|
||||||
deterUser: deterrenceFlow.deterUser,
|
deterUser,
|
||||||
undeterUser: deterrenceFlow.undeterUser,
|
undeterUser,
|
||||||
isVerified: verificationFlow.isVerified,
|
isVerified: (socket) => Boolean(socket?.data?.isVerified),
|
||||||
isDeterred: deterrenceFlow.isDeterred,
|
isDeterred: (socket) => Boolean(socket?.data?.isDeterred),
|
||||||
reevaluateSocketVerification,
|
reevaluateSocketVerification,
|
||||||
reevaluateSocketDeterrence,
|
reevaluateSocketDeterrence,
|
||||||
verificationEvents,
|
verificationEvents,
|
||||||
|
|||||||
Generated
+7
@@ -8,6 +8,7 @@
|
|||||||
"name": "webui",
|
"name": "webui",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@thumbmarkjs/thumbmarkjs": "^1.10.0",
|
||||||
"midi-file": "^1.2.4",
|
"midi-file": "^1.2.4",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
@@ -1417,6 +1418,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@thumbmarkjs/thumbmarkjs": "^1.10.0",
|
||||||
"midi-file": "^1.2.4",
|
"midi-file": "^1.2.4",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
|||||||
@@ -337,8 +337,8 @@ export function SessionProvider({ children }) {
|
|||||||
const actions = useMemo(
|
const actions = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
login: (username, password) => emitWithAck('auth:login', { username, password }),
|
login: (username, password) => emitWithAck('auth:login', { username, password }),
|
||||||
identifySession: ({ cookieUserId, nickname, overseerEnabled, identitySurface } = {}) =>
|
identifySession: ({ cookieUserId, fingerprintId, nickname, overseerEnabled, identitySurface } = {}) =>
|
||||||
emitWithAck('session:identify', { cookieUserId, nickname, overseerEnabled, identitySurface }),
|
emitWithAck('session:identify', { cookieUserId, fingerprintId, nickname, overseerEnabled, identitySurface }),
|
||||||
setRole: (role) => emitWithAck('session:setRole', { role }),
|
setRole: (role) => emitWithAck('session:setRole', { role }),
|
||||||
requestControl: (roverId, options = {}) =>
|
requestControl: (roverId, options = {}) =>
|
||||||
emitWithAck('session:requestControl', { roverId, ...options }),
|
emitWithAck('session:requestControl', { roverId, ...options }),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useCallback, useEffect, useRef } from 'react';
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
import { useSessionActions, useSessionSelector } from '../context/SessionContext.jsx';
|
import { useSessionActions, useSessionSelector } from '../context/SessionContext.jsx';
|
||||||
import { useSocket } from '../context/SocketContext.jsx';
|
import { useSocket } from '../context/SocketContext.jsx';
|
||||||
|
import { getBrowserFingerprintId } from '../lib/browserFingerprint.js';
|
||||||
import { useSettingsNamespace } from '../settings/index.js';
|
import { useSettingsNamespace } from '../settings/index.js';
|
||||||
|
|
||||||
export default function useUserIdentitySync({ identitySurface = 'passive' } = {}) {
|
export default function useUserIdentitySync({ identitySurface = 'passive' } = {}) {
|
||||||
@@ -21,6 +22,7 @@ export default function useUserIdentitySync({ identitySurface = 'passive' } = {}
|
|||||||
const inFlightRef = useRef(false);
|
const inFlightRef = useRef(false);
|
||||||
const lastAckSocketRef = useRef(null);
|
const lastAckSocketRef = useRef(null);
|
||||||
const retryTimerRef = useRef(null);
|
const retryTimerRef = useRef(null);
|
||||||
|
const fingerprintRef = useRef('');
|
||||||
|
|
||||||
const ready =
|
const ready =
|
||||||
identityStatus === 'ready' && profileStatus === 'ready' && overseerPreferenceStatus === '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;
|
if (!ready || !connected || !socket?.id || inFlightRef.current) return;
|
||||||
inFlightRef.current = true;
|
inFlightRef.current = true;
|
||||||
try {
|
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
|
Every route shares the same persisted identity key, but only the main
|
||||||
driver page should trigger duplicate-tab prevention. Sending the surface
|
driver page should trigger duplicate-tab prevention. Sending the surface
|
||||||
@@ -48,6 +58,7 @@ export default function useUserIdentitySync({ identitySurface = 'passive' } = {}
|
|||||||
*/
|
*/
|
||||||
const resp = await identifySession({
|
const resp = await identifySession({
|
||||||
cookieUserId,
|
cookieUserId,
|
||||||
|
fingerprintId: fingerprintRef.current,
|
||||||
nickname,
|
nickname,
|
||||||
overseerEnabled,
|
overseerEnabled,
|
||||||
identitySurface: normalizedIdentitySurface,
|
identitySurface: normalizedIdentitySurface,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user