diff --git a/rulesdocs/refector_rules_and_tracking.md b/rulesdocs/refector_rules_and_tracking.md index c20962e9..0975263a 100644 --- a/rulesdocs/refector_rules_and_tracking.md +++ b/rulesdocs/refector_rules_and_tracking.md @@ -53,8 +53,8 @@ - [ ] rover manager service (in progress: constants/state extracted) - [x] session service - [x] turn service -- [ ] verification service -- [ ] video auth service +- [x] verification service +- [x] video auth service - [x] All remaining services: reorganize to folder structure where needed ### COMPLETED SERVICES @@ -81,6 +81,8 @@ - Began `audioForwardService` decomposition by extracting permission/path policy helpers to `audioForwardService/policy.js` and rover/turn/socket event wiring to `audioForwardService/hooks.js`; rewired service entrypoint to use extracted modules. - Continued `audioForwardService` decomposition by extracting ffmpeg worker lifecycle, upload playback, and WHIP ownership/session control into `audioForwardService/workerEngine.js`; `audioForwardService/index.js` is now a thin composition layer. - Finished `buttonBoxService` decomposition by extracting persisted state management to `buttonBoxService/store.js`, reward/effect workflows to `buttonBoxService/core.js`, and HTTP transport wiring to `buttonBoxService/httpRoute.js`; `buttonBoxService/index.js` is now a thin composition layer. +- Finished `verificationService` decomposition by extracting persisted store handling to `verificationService/store.js`, identity/selector normalization to `verificationService/identity.js`, verification/deterrence/request lifecycle logic to `verificationService/verificationFlow.js`, `verificationService/deterrenceFlow.js`, and `verificationService/requestFlow.js`, plus socket/role event wiring to `verificationService/hooks.js`; `verificationService/index.js` is now a thin composition layer. +- Finished `videoAuthService` decomposition by extracting MediaMTX stream parsing to `videoAuthService/streamParsing.js`, role/mode/stream policy checks to `videoAuthService/policy.js`, and auth HTTP transport wiring to `videoAuthService/httpRoute.js`; `videoAuthService/index.js` is now a thin composition layer. ## WebUI frontend ### BIGGEST OFFENDERS diff --git a/server/src/services/verificationService/deterrenceFlow.js b/server/src/services/verificationService/deterrenceFlow.js new file mode 100644 index 00000000..6f839e65 --- /dev/null +++ b/server/src/services/verificationService/deterrenceFlow.js @@ -0,0 +1,289 @@ +// Deterrence Flow Module +// Purpose: Implements deterred-user matching and moderation operations that block abusive identities. +// Scope: Owns deter/undeter logic and socket deterrence reevaluation for all connected clients. +const crypto = require('crypto'); + +function createDeterrenceFlow(deps) { + const { + loadStore, + withStore, + io, + publishEvent, + emitChange, + getRole, + ensureSocketData, + identityFromSocket, + normalizeNicknameKey, + normalizeKnownIps, + isAdminRole, + parseDeterrenceSelector, + isRawIp, + normalizeCookieUserId, + isValidCookieUserId, + sanitizeNickname, + } = deps; + + function findDeterredMatch(store, { cookieUserId, nickname, ip }) { + const nicknameKey = normalizeNicknameKey(nickname); + return ( + (store.deterredUsers || []).find((entry) => { + const entryCookie = normalizeCookieUserId(entry.cookieUserId); + if (cookieUserId && entryCookie && entryCookie === cookieUserId) return true; + const entryIps = normalizeKnownIps(entry.knownIps); + if (ip && entryIps.includes(ip)) return true; + const entryNicknameKey = normalizeNicknameKey(entry.nickname); + if (nicknameKey && entryNicknameKey && entryNicknameKey === nicknameKey) return true; + return false; + }) || null + ); + } + + function reevaluateSocketDeterrence(socket) { + if (!socket) return { isDeterred: false, matchedRecordId: null, reason: 'missing_socket' }; + const store = loadStore(); + const data = ensureSocketData(socket); + const role = getRole(socket); + const { cookieUserId, nickname, ip } = identityFromSocket(socket); + + if (isAdminRole(role)) { + data.isDeterred = false; + data.deterredRecordId = null; + return { + isDeterred: false, + matchedRecordId: null, + reason: 'admin_bypass', + cookieUserId, + nickname, + ip, + }; + } + + const match = findDeterredMatch(store, { cookieUserId, nickname, ip }); + const isDeterred = Boolean(match); + data.isDeterred = isDeterred; + data.deterredRecordId = isDeterred ? match.id : null; + + if (isDeterred) { + withStore((draft) => { + const record = (draft.deterredUsers || []).find((entry) => entry.id === match.id); + if (!record) return; + record.updatedAt = Date.now(); + if (cookieUserId) { + record.cookieUserId = cookieUserId; + } + if (nickname) { + record.nickname = nickname; + } + record.knownIps = normalizeKnownIps(record.knownIps); + if (ip && !record.knownIps.includes(ip)) { + record.knownIps.push(ip); + } + }); + } + + return { + isDeterred, + matchedRecordId: isDeterred ? match.id : null, + reason: isDeterred ? 'matched' : 'no_match', + cookieUserId, + nickname, + ip, + }; + } + + function getModerationStateForSocket(socket) { + const data = socket?.data || {}; + return { + isDeterred: Boolean(data.isDeterred), + recordId: data.deterredRecordId || null, + }; + } + + function maybeResolveVerifiedRecordForSelector(store, parsed) { + if (parsed.cookieUserId) { + return store.verifiedUsers.find((entry) => normalizeCookieUserId(entry.cookieUserId) === parsed.cookieUserId) || null; + } + if (parsed.ip) { + return store.verifiedUsers.find((entry) => Array.isArray(entry.knownIps) && entry.knownIps.includes(parsed.ip)) || null; + } + const byNickname = store.verifiedUsers.filter((entry) => normalizeNicknameKey(entry.nickname) === normalizeNicknameKey(parsed.nickname)); + if (byNickname.length === 1) return byNickname[0]; + return null; + } + + function listDeterredUsers() { + const store = loadStore(); + return (store.deterredUsers || []).map((entry) => ({ ...entry, knownIps: [...(entry.knownIps || [])] })); + } + + function deterUser(selector, options = {}) { + const parsed = parseDeterrenceSelector(selector); + const reasonRaw = String(options?.reason || '').trim(); + const reason = reasonRaw ? reasonRaw.slice(0, 240) : null; + const actor = options?.actor ? String(options.actor) : null; + const now = Date.now(); + + let result = null; + + withStore((draft) => { + const verified = maybeResolveVerifiedRecordForSelector(draft, parsed); + const cookieUserId = parsed.cookieUserId || normalizeCookieUserId(verified?.cookieUserId || ''); + const nickname = parsed.nickname || sanitizeNickname(verified?.nickname || ''); + const knownIps = normalizeKnownIps([ + ...(parsed.ip ? [parsed.ip] : []), + ...((verified && Array.isArray(verified.knownIps)) ? verified.knownIps : []), + ]); + + let existing = findDeterredMatch(draft, { cookieUserId, nickname, ip: parsed.ip || null }); + if (!existing && cookieUserId) { + existing = (draft.deterredUsers || []).find((entry) => normalizeCookieUserId(entry.cookieUserId) === cookieUserId) || null; + } + + if (existing) { + if (cookieUserId) { + existing.cookieUserId = cookieUserId; + } + if (nickname) { + existing.nickname = nickname; + } + const mergedIps = normalizeKnownIps([...(existing.knownIps || []), ...knownIps]); + existing.knownIps = mergedIps; + if (reason) { + existing.reason = reason; + } + existing.updatedAt = now; + existing.updatedBy = actor; + result = { ...existing, knownIps: [...(existing.knownIps || [])], created: false }; + return; + } + + const created = { + id: `du_${crypto.randomBytes(8).toString('hex')}`, + cookieUserId: cookieUserId || null, + nickname: nickname || null, + knownIps, + reason, + createdAt: now, + createdBy: actor, + updatedAt: now, + updatedBy: actor, + }; + draft.deterredUsers.push(created); + result = { ...created, knownIps: [...(created.knownIps || [])], created: true }; + }); + + io.sockets.sockets.forEach((socket) => { + reevaluateSocketDeterrence(socket); + }); + + emitChange('deter_update'); + publishEvent({ + source: 'moderation', + type: result?.created ? 'moderation.deterred' : 'moderation.deterrenceUpdated', + payload: { + id: result?.id || null, + cookieUserId: result?.cookieUserId || null, + nickname: result?.nickname || null, + knownIps: result?.knownIps || [], + reason: result?.reason || null, + actor, + ts: now, + }, + }); + + return result; + } + + function resolveDeterredSelector(selector) { + const store = loadStore(); + const value = String(selector || '').trim(); + if (!value) return { error: 'selector_required' }; + + const byId = (store.deterredUsers || []).find((entry) => String(entry.id) === value) || null; + if (byId) return { record: byId }; + + const cookie = normalizeCookieUserId(value); + if (cookie && isValidCookieUserId(cookie)) { + const byCookie = (store.deterredUsers || []).find((entry) => normalizeCookieUserId(entry.cookieUserId) === cookie) || null; + if (byCookie) return { record: byCookie }; + } + + const ipValue = typeof value === 'string' ? value.trim() : ''; + if (isRawIp(ipValue)) { + const byIp = + (store.deterredUsers || []).find((entry) => Array.isArray(entry.knownIps) && entry.knownIps.includes(ipValue)) || null; + if (byIp) return { record: byIp }; + } + + const nicknameKey = normalizeNicknameKey(value); + const byNickname = (store.deterredUsers || []).filter((entry) => normalizeNicknameKey(entry.nickname) === nicknameKey); + if (byNickname.length === 1) return { record: byNickname[0] }; + if (byNickname.length > 1) return { error: 'ambiguous_nickname' }; + + return { error: 'not_found' }; + } + + function undeterUser(selector, removedBy = null) { + const resolved = resolveDeterredSelector(selector); + if (resolved.error) { + throw new Error( + resolved.error === 'ambiguous_nickname' + ? 'Nickname matches multiple deterred users; remove by id or cookieUserId.' + : 'Deterred user not found.', + ); + } + + const target = resolved.record; + let removed = null; + + withStore((draft) => { + const before = draft.deterredUsers.length; + draft.deterredUsers = draft.deterredUsers.filter((entry) => entry.id !== target.id); + if (draft.deterredUsers.length !== before) { + removed = target; + } + }); + + if (!removed) { + throw new Error('Deterred user not found.'); + } + + io.sockets.sockets.forEach((socket) => { + reevaluateSocketDeterrence(socket); + }); + + const removedAt = Date.now(); + emitChange('deter_remove'); + publishEvent({ + source: 'moderation', + type: 'moderation.undeterred', + payload: { + id: removed.id, + cookieUserId: removed.cookieUserId || null, + nickname: removed.nickname || null, + removedBy: removedBy ? String(removedBy) : null, + removedAt, + }, + }); + + return { ...removed, knownIps: [...(removed.knownIps || [])] }; + } + + function isDeterred(socket) { + return Boolean(socket?.data?.isDeterred); + } + + return { + findDeterredMatch, + reevaluateSocketDeterrence, + getModerationStateForSocket, + listDeterredUsers, + deterUser, + undeterUser, + isDeterred, + }; +} + +module.exports = { + createDeterrenceFlow, +}; diff --git a/server/src/services/verificationService/hooks.js b/server/src/services/verificationService/hooks.js new file mode 100644 index 00000000..6bcd1c41 --- /dev/null +++ b/server/src/services/verificationService/hooks.js @@ -0,0 +1,52 @@ +// Verification Hooks Module +// Purpose: Registers socket and role-change event hooks for identity, verification requests, and reevaluation. +// Scope: Binds framework events to verification-service flows without owning verification business logic. +function registerVerificationHooks(deps) { + const { + io, + roleEvents, + logger, + identifySocket, + createVerificationRequest, + reevaluateSocketVerification, + reevaluateSocketDeterrence, + emitChange, + } = deps; + + io.on('connection', (socket) => { + identifySocket(socket, {}); + + socket.on('session:identify', (payload = {}, cb = () => {}) => { + try { + const result = identifySocket(socket, payload || {}); + 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 { + reevaluateSocketVerification(socket); + reevaluateSocketDeterrence(socket); + emitChange('role_change', { socketId: socket.id }); + } catch (err) { + logger.warn('Failed to reevaluate verification on role change', err.message); + } + }); +} + +module.exports = { + registerVerificationHooks, +}; diff --git a/server/src/services/verificationService/identity.js b/server/src/services/verificationService/identity.js new file mode 100644 index 00000000..80d4cd85 --- /dev/null +++ b/server/src/services/verificationService/identity.js @@ -0,0 +1,86 @@ +// Verification Identity Helpers +// Purpose: Centralizes socket identity extraction and normalization for verification/moderation decisions. +// Scope: Keeps role checks and selector normalization consistent across all verification-service flows. +const net = require('net'); +const { normalizeIp } = require('../../helpers/ipResolver'); +const { getNickname } = require('../nicknameService'); +const { + sanitizeNickname, + normalizeCookieUserId, + isValidCookieUserId, + generateCookieUserId, + getKnownIp, +} = require('../identityService'); + +function ensureSocketData(socket) { + socket.data = socket.data || {}; + return socket.data; +} + +function identityFromSocket(socket) { + const data = ensureSocketData(socket); + return { + cookieUserId: normalizeCookieUserId(data.cookieUserId), + nickname: sanitizeNickname(getNickname(socket)), + ip: getKnownIp(socket), + }; +} + +function normalizeNicknameKey(value) { + return sanitizeNickname(value).toLowerCase(); +} + +function normalizeKnownIps(raw = []) { + const out = []; + (Array.isArray(raw) ? raw : []).forEach((value) => { + const ip = typeof value === 'string' ? value.trim() : ''; + if (!ip) return; + if (!out.includes(ip)) { + out.push(ip); + } + }); + return out; +} + +function isAdminRole(role) { + return role === 'admin' || role === 'lockdown' || role === 'lockdown-admin'; +} + +function parseDeterrenceSelector(selector) { + const value = String(selector || '').trim(); + if (!value) { + throw new Error('Selector required.'); + } + const cookie = normalizeCookieUserId(value); + if (cookie && isValidCookieUserId(cookie)) { + return { cookieUserId: cookie, nickname: '', ip: null }; + } + const ip = normalizeIp(value); + if (ip && net.isIP(ip)) { + return { cookieUserId: '', nickname: '', ip }; + } + const nickname = sanitizeNickname(value); + if (!nickname) { + throw new Error('Selector required.'); + } + return { cookieUserId: '', nickname, ip: null }; +} + +function isRawIp(value) { + const ip = typeof value === 'string' ? value.trim() : ''; + return Boolean(ip && net.isIP(ip)); +} + +module.exports = { + ensureSocketData, + identityFromSocket, + normalizeNicknameKey, + normalizeKnownIps, + isAdminRole, + parseDeterrenceSelector, + isRawIp, + normalizeCookieUserId, + isValidCookieUserId, + generateCookieUserId, + sanitizeNickname, +}; diff --git a/server/src/services/verificationService/index.js b/server/src/services/verificationService/index.js index 3d409278..df9c815c 100644 --- a/server/src/services/verificationService/index.js +++ b/server/src/services/verificationService/index.js @@ -1,231 +1,91 @@ -// verification Service -// Purpose: Defines the verification Service module and the helpers/state used by this service unit. -// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary. -const crypto = require('crypto'); -const net = require('net'); +// Verification Service Module +// Purpose: Composes verification identity, request, and moderation deterrence flows into one public service API. +// Scope: Exposes stable verification operations while delegating behavior to focused submodules. const EventEmitter = require('events'); const io = require('../../globals/io'); const logger = require('../../globals/logger').child('verificationService'); -const { resolveDataPath } = require('../../helpers/dataPaths'); const { publishEvent } = require('../eventBus'); -const { normalizeIp } = require('../../helpers/ipResolver'); const { getNickname, setNickname } = require('../nicknameService'); const { getRole, roleEvents } = require('../roleService'); + +const { loadStore, withStore } = require('./store'); const { - sanitizeNickname, + ensureSocketData, + identityFromSocket, + normalizeNicknameKey, + normalizeKnownIps, + isAdminRole, + parseDeterrenceSelector, + isRawIp, normalizeCookieUserId, isValidCookieUserId, generateCookieUserId, - getKnownIp, - createJsonStore, -} = require('../identityService'); - -const STORE_PATH = resolveDataPath('verified-users.json'); + sanitizeNickname, +} = require('./identity'); +const { createVerificationFlow } = require('./verificationFlow'); +const { createDeterrenceFlow } = require('./deterrenceFlow'); +const { createRequestFlow } = require('./requestFlow'); +const { registerVerificationHooks } = require('./hooks'); const verificationEvents = new EventEmitter(); -function normalizeStoreShape(store) { - const next = store && typeof store === 'object' ? store : {}; - return { - verifiedUsers: Array.isArray(next.verifiedUsers) ? next.verifiedUsers : [], - pendingRequests: Array.isArray(next.pendingRequests) ? next.pendingRequests : [], - dmMessages: Array.isArray(next.dmMessages) ? next.dmMessages : [], - deterredUsers: Array.isArray(next.deterredUsers) ? next.deterredUsers : [], - }; -} - -function cloneStore(current) { - return { - verifiedUsers: (current.verifiedUsers || []).map((entry) => ({ ...entry, knownIps: [...(entry.knownIps || [])] })), - pendingRequests: (current.pendingRequests || []).map((entry) => ({ ...entry })), - dmMessages: (current.dmMessages || []).map((entry) => ({ ...entry })), - deterredUsers: (current.deterredUsers || []).map((entry) => ({ ...entry, knownIps: [...(entry.knownIps || [])] })), - }; -} - -const storeApi = createJsonStore({ - path: STORE_PATH, - normalizeStoreShape, - cloneStore, - logger, -}); - -const { loadStore, withStore } = storeApi; - -function ensureSocketData(socket) { - socket.data = socket.data || {}; - return socket.data; -} - -function identityFromSocket(socket) { - const data = ensureSocketData(socket); - return { - cookieUserId: normalizeCookieUserId(data.cookieUserId), - nickname: sanitizeNickname(getNickname(socket)), - ip: getKnownIp(socket), - }; -} - -function normalizeNicknameKey(value) { - return sanitizeNickname(value).toLowerCase(); -} - -function normalizeKnownIps(raw = []) { - const out = []; - (Array.isArray(raw) ? raw : []).forEach((value) => { - const ip = typeof value === 'string' ? value.trim() : ''; - if (!ip) return; - if (!out.includes(ip)) { - out.push(ip); - } - }); - return out; -} - -function findVerifiedMatch(store, { cookieUserId, ip }) { - if (!cookieUserId && !ip) return null; - const byCookie = cookieUserId - ? store.verifiedUsers.find((entry) => normalizeCookieUserId(entry.cookieUserId) === cookieUserId) || null - : null; - if (byCookie) return byCookie; - if (!ip) return null; - return ( - store.verifiedUsers.find((entry) => Array.isArray(entry.knownIps) && entry.knownIps.includes(ip)) || null - ); -} - -function isAdminRole(role) { - return role === 'admin' || role === 'lockdown' || role === 'lockdown-admin'; -} - -function findDeterredMatch(store, { cookieUserId, nickname, ip }) { - const nicknameKey = normalizeNicknameKey(nickname); - return ( - (store.deterredUsers || []).find((entry) => { - const entryCookie = normalizeCookieUserId(entry.cookieUserId); - if (cookieUserId && entryCookie && entryCookie === cookieUserId) return true; - const entryIps = normalizeKnownIps(entry.knownIps); - if (ip && entryIps.includes(ip)) return true; - const entryNicknameKey = normalizeNicknameKey(entry.nickname); - if (nicknameKey && entryNicknameKey && entryNicknameKey === nicknameKey) return true; - return false; - }) || null - ); -} - function emitChange(reason, payload = {}) { verificationEvents.emit('change', { reason, ...payload }); } -function reevaluateSocketVerification(socket) { - if (!socket) return { isVerified: false, matchedRecordId: null, reason: 'missing_socket' }; - const store = loadStore(); - const data = ensureSocketData(socket); - const role = getRole(socket); - const { cookieUserId, nickname, ip } = identityFromSocket(socket); +let reevaluateSocketVerification = () => ({ isVerified: false, matchedRecordId: null, reason: 'not_initialized' }); +let reevaluateSocketDeterrence = () => ({ isDeterred: false, matchedRecordId: null, reason: 'not_initialized' }); - if (role === 'lockdown') { - data.isVerified = true; - data.verifiedRecordId = null; - return { - isVerified: true, - matchedRecordId: null, - reason: 'lockdown_admin', - cookieUserId, - nickname, - ip, - }; - } +const verificationFlow = createVerificationFlow({ + loadStore, + withStore, + io, + publishEvent, + emitChange, + getRole, + getNickname, + ensureSocketData, + identityFromSocket, + normalizeCookieUserId, + sanitizeNickname, + reevaluateSocketDeterrence: (...args) => reevaluateSocketDeterrence(...args), +}); +reevaluateSocketVerification = verificationFlow.reevaluateSocketVerification; - const match = findVerifiedMatch(store, { cookieUserId, ip }); - const nicknameMatches = Boolean(match && nickname && sanitizeNickname(match.nickname) === nickname); +const deterrenceFlow = createDeterrenceFlow({ + loadStore, + withStore, + io, + publishEvent, + emitChange, + getRole, + ensureSocketData, + identityFromSocket, + normalizeNicknameKey, + normalizeKnownIps, + isAdminRole, + parseDeterrenceSelector, + isRawIp, + normalizeCookieUserId, + isValidCookieUserId, + sanitizeNickname, + findVerifiedMatch: verificationFlow.findVerifiedMatch, +}); +reevaluateSocketDeterrence = deterrenceFlow.reevaluateSocketDeterrence; - let isVerified = false; - let reason = 'no_match'; - if (match && nicknameMatches) { - isVerified = true; - reason = 'matched'; - } else if (match && !nicknameMatches) { - reason = 'nickname_mismatch'; - } - - data.isVerified = isVerified; - data.verifiedRecordId = isVerified ? match.id : null; - - if (isVerified) { - withStore((draft) => { - const record = draft.verifiedUsers.find((entry) => entry.id === match.id); - if (!record) return; - record.updatedAt = Date.now(); - record.nickname = nickname; - if (ip && !record.knownIps.includes(ip)) { - record.knownIps.push(ip); - } - }); - } - - return { - isVerified, - matchedRecordId: isVerified ? match.id : null, - reason, - cookieUserId, - nickname, - ip, - }; -} - -function reevaluateSocketDeterrence(socket) { - if (!socket) return { isDeterred: false, matchedRecordId: null, reason: 'missing_socket' }; - const store = loadStore(); - const data = ensureSocketData(socket); - const role = getRole(socket); - const { cookieUserId, nickname, ip } = identityFromSocket(socket); - - if (isAdminRole(role)) { - data.isDeterred = false; - data.deterredRecordId = null; - return { - isDeterred: false, - matchedRecordId: null, - reason: 'admin_bypass', - cookieUserId, - nickname, - ip, - }; - } - - const match = findDeterredMatch(store, { cookieUserId, nickname, ip }); - const isDeterred = Boolean(match); - data.isDeterred = isDeterred; - data.deterredRecordId = isDeterred ? match.id : null; - - if (isDeterred) { - withStore((draft) => { - const record = (draft.deterredUsers || []).find((entry) => entry.id === match.id); - if (!record) return; - record.updatedAt = Date.now(); - if (cookieUserId) { - record.cookieUserId = cookieUserId; - } - if (nickname) { - record.nickname = nickname; - } - record.knownIps = normalizeKnownIps(record.knownIps); - if (ip && !record.knownIps.includes(ip)) { - record.knownIps.push(ip); - } - }); - } - - return { - isDeterred, - matchedRecordId: isDeterred ? match.id : null, - reason: isDeterred ? 'matched' : 'no_match', - cookieUserId, - nickname, - ip, - }; -} +const requestFlow = createRequestFlow({ + loadStore, + withStore, + io, + publishEvent, + emitChange, + ensureSocketData, + identityFromSocket, + isValidCookieUserId, + normalizeCookieUserId, + reevaluateSocketVerification, + reevaluateSocketDeterrence, +}); function identifySocket(socket, payload = {}) { if (!socket) { @@ -263,537 +123,39 @@ function identifySocket(socket, payload = {}) { }; } -function getVerificationStatus(socket) { - const data = socket?.data || {}; - return { - isVerified: Boolean(data.isVerified), - recordId: data.verifiedRecordId || null, - }; -} - -function getIdentitySummary(socket) { - const data = socket?.data || {}; - return { - cookieUserId: normalizeCookieUserId(data.cookieUserId) || null, - nickname: getNickname(socket) || null, - }; -} - -function getPendingRequestForIdentity(cookieUserId) { - const key = normalizeCookieUserId(cookieUserId); - if (!key) return null; - const store = loadStore(); - return store.pendingRequests.find((entry) => entry.status === 'pending' && entry.cookieUserId === key) || null; -} - -function listVerifiedUsers() { - const store = loadStore(); - return store.verifiedUsers.map((entry) => ({ ...entry, knownIps: [...(entry.knownIps || [])] })); -} - -function resolveVerifiedUserSelector(selector) { - const value = String(selector || '').trim(); - if (!value) return { error: 'selector_required' }; - const store = loadStore(); - const byCookie = store.verifiedUsers.find((entry) => entry.cookieUserId === value) || null; - if (byCookie) return { record: byCookie }; - const byNickname = store.verifiedUsers.filter((entry) => sanitizeNickname(entry.nickname) === sanitizeNickname(value)); - if (byNickname.length === 1) return { record: byNickname[0] }; - if (byNickname.length > 1) return { error: 'ambiguous_nickname' }; - return { error: 'not_found' }; -} - -function removeVerifiedUser(selector, removedBy = null) { - const resolved = resolveVerifiedUserSelector(selector); - if (resolved.error) { - throw new Error( - resolved.error === 'ambiguous_nickname' - ? 'Nickname matches multiple users; remove by cookieUserId.' - : 'Verified user not found.', - ); - } - const target = resolved.record; - let removed = null; - withStore((draft) => { - const before = draft.verifiedUsers.length; - draft.verifiedUsers = draft.verifiedUsers.filter((entry) => entry.id !== target.id); - if (draft.verifiedUsers.length !== before) { - removed = target; - } - }); - if (!removed) { - throw new Error('Verified user not found.'); - } - - io.sockets.sockets.forEach((socket) => { - const data = ensureSocketData(socket); - if (normalizeCookieUserId(data.cookieUserId) === removed.cookieUserId) { - reevaluateSocketVerification(socket); - reevaluateSocketDeterrence(socket); - } - }); - - emitChange('remove', { cookieUserId: removed.cookieUserId }); - publishEvent({ - source: 'verification', - type: 'verification.userRemoved', - payload: { - cookieUserId: removed.cookieUserId, - nickname: removed.nickname, - removedBy, - removedAt: Date.now(), - }, - }); - - return removed; -} - -function createVerificationRequest(socket) { - if (!socket) { - throw new Error('Socket required'); - } - const data = ensureSocketData(socket); - const { cookieUserId, nickname, ip } = identityFromSocket(socket); - - if (data.isVerified) { - throw new Error('You are already verified.'); - } - - if (!cookieUserId) { - throw new Error('Identity key missing. Reconnect and try again.'); - } - if (!isValidCookieUserId(cookieUserId)) { - throw new Error('Identity key format invalid.'); - } - if (!nickname) { - throw new Error('Nickname required before requesting verification.'); - } - - const existingPending = getPendingRequestForIdentity(cookieUserId); - if (existingPending) { - return existingPending; - } - - const request = { - id: `vr_${crypto.randomBytes(8).toString('hex')}`, - status: 'pending', - cookieUserId, - nickname, - ip, - socketId: socket.id, - createdAt: Date.now(), - resolvedAt: null, - resolvedBy: null, - decision: null, - }; - - withStore((draft) => { - draft.pendingRequests.push(request); - }); - - publishEvent({ source: 'verification', type: 'verification.requested', payload: request }); - emitChange('request', { requestId: request.id, socketId: socket.id }); - return request; -} - -function attachDmMessage(requestId, messageId, adminDiscordId) { - if (!requestId || !messageId) return; - withStore((draft) => { - const exists = draft.dmMessages.find((entry) => entry.messageId === messageId); - if (exists) return; - draft.dmMessages.push({ - requestId, - messageId, - adminDiscordId: adminDiscordId ? String(adminDiscordId) : null, - createdAt: Date.now(), - }); - }); -} - -function getPendingRequestById(requestId) { - if (!requestId) return null; - const store = loadStore(); - return store.pendingRequests.find((entry) => entry.id === requestId && entry.status === 'pending') || null; -} - -function getRequestByMessageId(messageId) { - if (!messageId) return null; - const store = loadStore(); - const map = store.dmMessages.find((entry) => entry.messageId === messageId); - if (!map) return null; - const request = store.pendingRequests.find((entry) => entry.id === map.requestId) || null; - return request ? { request, map } : null; -} - -function approveRequest(requestId, actorDiscordId) { - const request = getPendingRequestById(requestId); - if (!request) { - throw new Error('Request not found or already resolved.'); - } - - const approvedAt = Date.now(); - const actor = actorDiscordId ? String(actorDiscordId) : null; - - withStore((draft) => { - const pending = draft.pendingRequests.find((entry) => entry.id === requestId); - if (!pending || pending.status !== 'pending') { - throw new Error('Request not found or already resolved.'); - } - pending.status = 'approved'; - pending.decision = 'approved'; - pending.resolvedAt = approvedAt; - pending.resolvedBy = actor; - - let target = - draft.verifiedUsers.find((entry) => entry.cookieUserId === pending.cookieUserId) || - draft.verifiedUsers.find((entry) => Array.isArray(entry.knownIps) && entry.knownIps.includes(pending.ip)); - - if (!target) { - target = { - id: `vu_${crypto.randomBytes(8).toString('hex')}`, - cookieUserId: pending.cookieUserId, - nickname: pending.nickname, - knownIps: pending.ip ? [pending.ip] : [], - createdAt: approvedAt, - updatedAt: approvedAt, - approvedBy: actor, - }; - draft.verifiedUsers.push(target); - } else { - target.cookieUserId = pending.cookieUserId; - target.nickname = pending.nickname; - if (pending.ip && !target.knownIps.includes(pending.ip)) { - target.knownIps.push(pending.ip); - } - target.updatedAt = approvedAt; - target.approvedBy = actor; - } - }); - - io.sockets.sockets.forEach((socket) => { - const data = ensureSocketData(socket); - if (normalizeCookieUserId(data.cookieUserId) === request.cookieUserId) { - reevaluateSocketVerification(socket); - reevaluateSocketDeterrence(socket); - } - }); - - publishEvent({ - source: 'verification', - type: 'verification.resolved', - payload: { - requestId, - decision: 'approved', - cookieUserId: request.cookieUserId, - nickname: request.nickname, - resolvedBy: actor, - resolvedAt: approvedAt, - }, - }); - emitChange('approve', { requestId }); -} - -function denyRequest(requestId, actorDiscordId) { - const request = getPendingRequestById(requestId); - if (!request) { - throw new Error('Request not found or already resolved.'); - } - - const deniedAt = Date.now(); - const actor = actorDiscordId ? String(actorDiscordId) : null; - - withStore((draft) => { - const pending = draft.pendingRequests.find((entry) => entry.id === requestId); - if (!pending || pending.status !== 'pending') { - throw new Error('Request not found or already resolved.'); - } - pending.status = 'denied'; - pending.decision = 'denied'; - pending.resolvedAt = deniedAt; - pending.resolvedBy = actor; - }); - - publishEvent({ - source: 'verification', - type: 'verification.resolved', - payload: { - requestId, - decision: 'denied', - cookieUserId: request.cookieUserId, - nickname: request.nickname, - resolvedBy: actor, - resolvedAt: deniedAt, - }, - }); - emitChange('deny', { requestId }); -} - function getVerificationStateForSocket(socket) { - const identity = getIdentitySummary(socket); - const pending = getPendingRequestForIdentity(identity.cookieUserId); - return { - isVerified: Boolean(socket?.data?.isVerified), - pendingRequestId: pending?.id || null, - pendingRequestedAt: pending?.createdAt || null, - }; + return verificationFlow.getVerificationStateForSocket(socket, requestFlow.getPendingRequestForIdentity); } -function getModerationStateForSocket(socket) { - const data = socket?.data || {}; - return { - isDeterred: Boolean(data.isDeterred), - recordId: data.deterredRecordId || null, - }; -} - -function isVerified(socket) { - return Boolean(socket?.data?.isVerified); -} - -function isDeterred(socket) { - return Boolean(socket?.data?.isDeterred); -} - -function parseDeterrenceSelector(selector) { - const value = String(selector || '').trim(); - if (!value) { - throw new Error('Selector required.'); - } - const cookie = normalizeCookieUserId(value); - if (cookie && isValidCookieUserId(cookie)) { - return { cookieUserId: cookie, nickname: '', ip: null }; - } - const ip = normalizeIp(value); - if (ip && net.isIP(ip)) { - return { cookieUserId: '', nickname: '', ip }; - } - const nickname = sanitizeNickname(value); - if (!nickname) { - throw new Error('Selector required.'); - } - return { cookieUserId: '', nickname, ip: null }; -} - -function maybeResolveVerifiedRecordForSelector(store, parsed) { - if (parsed.cookieUserId) { - return store.verifiedUsers.find((entry) => normalizeCookieUserId(entry.cookieUserId) === parsed.cookieUserId) || null; - } - if (parsed.ip) { - return store.verifiedUsers.find((entry) => Array.isArray(entry.knownIps) && entry.knownIps.includes(parsed.ip)) || null; - } - const byNickname = store.verifiedUsers.filter((entry) => normalizeNicknameKey(entry.nickname) === normalizeNicknameKey(parsed.nickname)); - if (byNickname.length === 1) return byNickname[0]; - return null; -} - -function listDeterredUsers() { - const store = loadStore(); - return (store.deterredUsers || []).map((entry) => ({ ...entry, knownIps: [...(entry.knownIps || [])] })); -} - -function deterUser(selector, options = {}) { - const parsed = parseDeterrenceSelector(selector); - const reasonRaw = String(options?.reason || '').trim(); - const reason = reasonRaw ? reasonRaw.slice(0, 240) : null; - const actor = options?.actor ? String(options.actor) : null; - const now = Date.now(); - - let result = null; - - withStore((draft) => { - const verified = maybeResolveVerifiedRecordForSelector(draft, parsed); - const cookieUserId = parsed.cookieUserId || normalizeCookieUserId(verified?.cookieUserId || ''); - const nickname = parsed.nickname || sanitizeNickname(verified?.nickname || ''); - const knownIps = normalizeKnownIps([ - ...(parsed.ip ? [parsed.ip] : []), - ...((verified && Array.isArray(verified.knownIps)) ? verified.knownIps : []), - ]); - - let existing = findDeterredMatch(draft, { cookieUserId, nickname, ip: parsed.ip || null }); - if (!existing && cookieUserId) { - existing = (draft.deterredUsers || []).find((entry) => normalizeCookieUserId(entry.cookieUserId) === cookieUserId) || null; - } - - if (existing) { - if (cookieUserId) { - existing.cookieUserId = cookieUserId; - } - if (nickname) { - existing.nickname = nickname; - } - const mergedIps = normalizeKnownIps([...(existing.knownIps || []), ...knownIps]); - existing.knownIps = mergedIps; - if (reason) { - existing.reason = reason; - } - existing.updatedAt = now; - existing.updatedBy = actor; - result = { ...existing, knownIps: [...(existing.knownIps || [])], created: false }; - return; - } - - const created = { - id: `du_${crypto.randomBytes(8).toString('hex')}`, - cookieUserId: cookieUserId || null, - nickname: nickname || null, - knownIps, - reason, - createdAt: now, - createdBy: actor, - updatedAt: now, - updatedBy: actor, - }; - draft.deterredUsers.push(created); - result = { ...created, knownIps: [...(created.knownIps || [])], created: true }; - }); - - io.sockets.sockets.forEach((socket) => { - reevaluateSocketDeterrence(socket); - }); - - emitChange('deter_update'); - publishEvent({ - source: 'moderation', - type: result?.created ? 'moderation.deterred' : 'moderation.deterrenceUpdated', - payload: { - id: result?.id || null, - cookieUserId: result?.cookieUserId || null, - nickname: result?.nickname || null, - knownIps: result?.knownIps || [], - reason: result?.reason || null, - actor, - ts: now, - }, - }); - - return result; -} - -function resolveDeterredSelector(selector) { - const store = loadStore(); - const value = String(selector || '').trim(); - if (!value) return { error: 'selector_required' }; - - const byId = (store.deterredUsers || []).find((entry) => String(entry.id) === value) || null; - if (byId) return { record: byId }; - - const cookie = normalizeCookieUserId(value); - if (cookie && isValidCookieUserId(cookie)) { - const byCookie = (store.deterredUsers || []).find((entry) => normalizeCookieUserId(entry.cookieUserId) === cookie) || null; - if (byCookie) return { record: byCookie }; - } - - const ip = typeof value === 'string' ? value.trim() : ''; - if (ip && net.isIP(ip)) { - const byIp = (store.deterredUsers || []).find((entry) => Array.isArray(entry.knownIps) && entry.knownIps.includes(ip)) || null; - if (byIp) return { record: byIp }; - } - - const nicknameKey = normalizeNicknameKey(value); - const byNickname = (store.deterredUsers || []).filter((entry) => normalizeNicknameKey(entry.nickname) === nicknameKey); - if (byNickname.length === 1) return { record: byNickname[0] }; - if (byNickname.length > 1) return { error: 'ambiguous_nickname' }; - - return { error: 'not_found' }; -} - -function undeterUser(selector, removedBy = null) { - const resolved = resolveDeterredSelector(selector); - if (resolved.error) { - throw new Error( - resolved.error === 'ambiguous_nickname' - ? 'Nickname matches multiple deterred users; remove by id or cookieUserId.' - : 'Deterred user not found.', - ); - } - - const target = resolved.record; - let removed = null; - - withStore((draft) => { - const before = draft.deterredUsers.length; - draft.deterredUsers = draft.deterredUsers.filter((entry) => entry.id !== target.id); - if (draft.deterredUsers.length !== before) { - removed = target; - } - }); - - if (!removed) { - throw new Error('Deterred user not found.'); - } - - io.sockets.sockets.forEach((socket) => { - reevaluateSocketDeterrence(socket); - }); - - const removedAt = Date.now(); - emitChange('deter_remove'); - publishEvent({ - source: 'moderation', - type: 'moderation.undeterred', - payload: { - id: removed.id, - cookieUserId: removed.cookieUserId || null, - nickname: removed.nickname || null, - removedBy: removedBy ? String(removedBy) : null, - removedAt, - }, - }); - - return { ...removed, knownIps: [...(removed.knownIps || [])] }; -} - -io.on('connection', (socket) => { - identifySocket(socket, {}); - - socket.on('session:identify', (payload = {}, cb = () => {}) => { - try { - const result = identifySocket(socket, payload || {}); - 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 { - reevaluateSocketVerification(socket); - reevaluateSocketDeterrence(socket); - emitChange('role_change', { socketId: socket.id }); - } catch (err) { - logger.warn('Failed to reevaluate verification on role change', err.message); - } +registerVerificationHooks({ + io, + roleEvents, + logger, + identifySocket, + createVerificationRequest: requestFlow.createVerificationRequest, + reevaluateSocketVerification, + reevaluateSocketDeterrence, + emitChange, }); module.exports = { identifySocket, - getVerificationStatus, - getIdentitySummary, + getVerificationStatus: verificationFlow.getVerificationStatus, + getIdentitySummary: verificationFlow.getIdentitySummary, getVerificationStateForSocket, - getModerationStateForSocket, - createVerificationRequest, - attachDmMessage, - getRequestByMessageId, - approveRequest, - denyRequest, - listVerifiedUsers, - removeVerifiedUser, - listDeterredUsers, - deterUser, - undeterUser, - isVerified, - isDeterred, + getModerationStateForSocket: deterrenceFlow.getModerationStateForSocket, + createVerificationRequest: requestFlow.createVerificationRequest, + attachDmMessage: requestFlow.attachDmMessage, + getRequestByMessageId: requestFlow.getRequestByMessageId, + approveRequest: requestFlow.approveRequest, + denyRequest: requestFlow.denyRequest, + listVerifiedUsers: verificationFlow.listVerifiedUsers, + removeVerifiedUser: verificationFlow.removeVerifiedUser, + listDeterredUsers: deterrenceFlow.listDeterredUsers, + deterUser: deterrenceFlow.deterUser, + undeterUser: deterrenceFlow.undeterUser, + isVerified: verificationFlow.isVerified, + isDeterred: deterrenceFlow.isDeterred, reevaluateSocketVerification, reevaluateSocketDeterrence, verificationEvents, diff --git a/server/src/services/verificationService/requestFlow.js b/server/src/services/verificationService/requestFlow.js new file mode 100644 index 00000000..00c7c473 --- /dev/null +++ b/server/src/services/verificationService/requestFlow.js @@ -0,0 +1,219 @@ +// Verification Request Flow Module +// Purpose: Handles verification request creation, mapping, and approve/deny lifecycle transitions. +// Scope: Owns pending request records and the side effects that update connected sockets and event streams. +const crypto = require('crypto'); + +function createRequestFlow(deps) { + const { + loadStore, + withStore, + io, + publishEvent, + emitChange, + ensureSocketData, + identityFromSocket, + isValidCookieUserId, + normalizeCookieUserId, + reevaluateSocketVerification, + reevaluateSocketDeterrence, + } = deps; + + function getPendingRequestForIdentity(cookieUserId) { + const key = normalizeCookieUserId(cookieUserId); + if (!key) return null; + const store = loadStore(); + return store.pendingRequests.find((entry) => entry.status === 'pending' && entry.cookieUserId === key) || null; + } + + function createVerificationRequest(socket) { + if (!socket) { + throw new Error('Socket required'); + } + const data = ensureSocketData(socket); + const { cookieUserId, nickname, ip } = identityFromSocket(socket); + + if (data.isVerified) { + throw new Error('You are already verified.'); + } + if (!cookieUserId) { + throw new Error('Identity key missing. Reconnect and try again.'); + } + if (!isValidCookieUserId(cookieUserId)) { + throw new Error('Identity key format invalid.'); + } + if (!nickname) { + throw new Error('Nickname required before requesting verification.'); + } + + const existingPending = getPendingRequestForIdentity(cookieUserId); + if (existingPending) { + return existingPending; + } + + const request = { + id: `vr_${crypto.randomBytes(8).toString('hex')}`, + status: 'pending', + cookieUserId, + nickname, + ip, + socketId: socket.id, + createdAt: Date.now(), + resolvedAt: null, + resolvedBy: null, + decision: null, + }; + + withStore((draft) => { + draft.pendingRequests.push(request); + }); + + publishEvent({ source: 'verification', type: 'verification.requested', payload: request }); + emitChange('request', { requestId: request.id, socketId: socket.id }); + return request; + } + + function attachDmMessage(requestId, messageId, adminDiscordId) { + if (!requestId || !messageId) return; + withStore((draft) => { + const exists = draft.dmMessages.find((entry) => entry.messageId === messageId); + if (exists) return; + draft.dmMessages.push({ + requestId, + messageId, + adminDiscordId: adminDiscordId ? String(adminDiscordId) : null, + createdAt: Date.now(), + }); + }); + } + + function getPendingRequestById(requestId) { + if (!requestId) return null; + const store = loadStore(); + return store.pendingRequests.find((entry) => entry.id === requestId && entry.status === 'pending') || null; + } + + function getRequestByMessageId(messageId) { + if (!messageId) return null; + const store = loadStore(); + const map = store.dmMessages.find((entry) => entry.messageId === messageId); + if (!map) return null; + const request = store.pendingRequests.find((entry) => entry.id === map.requestId) || null; + return request ? { request, map } : null; + } + + function approveRequest(requestId, actorDiscordId) { + const request = getPendingRequestById(requestId); + if (!request) { + throw new Error('Request not found or already resolved.'); + } + + const approvedAt = Date.now(); + const actor = actorDiscordId ? String(actorDiscordId) : null; + + withStore((draft) => { + const pending = draft.pendingRequests.find((entry) => entry.id === requestId); + if (!pending || pending.status !== 'pending') { + throw new Error('Request not found or already resolved.'); + } + pending.status = 'approved'; + pending.decision = 'approved'; + pending.resolvedAt = approvedAt; + pending.resolvedBy = actor; + + let target = + draft.verifiedUsers.find((entry) => entry.cookieUserId === pending.cookieUserId) || + draft.verifiedUsers.find((entry) => Array.isArray(entry.knownIps) && entry.knownIps.includes(pending.ip)); + + if (!target) { + target = { + id: `vu_${crypto.randomBytes(8).toString('hex')}`, + cookieUserId: pending.cookieUserId, + nickname: pending.nickname, + knownIps: pending.ip ? [pending.ip] : [], + createdAt: approvedAt, + updatedAt: approvedAt, + approvedBy: actor, + }; + draft.verifiedUsers.push(target); + } else { + target.cookieUserId = pending.cookieUserId; + target.nickname = pending.nickname; + if (pending.ip && !target.knownIps.includes(pending.ip)) { + target.knownIps.push(pending.ip); + } + target.updatedAt = approvedAt; + target.approvedBy = actor; + } + }); + + io.sockets.sockets.forEach((socket) => { + const data = ensureSocketData(socket); + if (normalizeCookieUserId(data.cookieUserId) === request.cookieUserId) { + reevaluateSocketVerification(socket); + reevaluateSocketDeterrence(socket); + } + }); + + publishEvent({ + source: 'verification', + type: 'verification.resolved', + payload: { + requestId, + decision: 'approved', + cookieUserId: request.cookieUserId, + nickname: request.nickname, + resolvedBy: actor, + resolvedAt: approvedAt, + }, + }); + emitChange('approve', { requestId }); + } + + function denyRequest(requestId, actorDiscordId) { + const request = getPendingRequestById(requestId); + if (!request) { + throw new Error('Request not found or already resolved.'); + } + + const deniedAt = Date.now(); + const actor = actorDiscordId ? String(actorDiscordId) : null; + + withStore((draft) => { + const pending = draft.pendingRequests.find((entry) => entry.id === requestId); + if (!pending || pending.status !== 'pending') { + throw new Error('Request not found or already resolved.'); + } + pending.status = 'denied'; + pending.decision = 'denied'; + pending.resolvedAt = deniedAt; + pending.resolvedBy = actor; + }); + + publishEvent({ + source: 'verification', + type: 'verification.resolved', + payload: { + requestId, + decision: 'denied', + cookieUserId: request.cookieUserId, + nickname: request.nickname, + resolvedBy: actor, + resolvedAt: deniedAt, + }, + }); + emitChange('deny', { requestId }); + } + + return { + getPendingRequestForIdentity, + createVerificationRequest, + attachDmMessage, + getRequestByMessageId, + approveRequest, + denyRequest, + }; +} + +module.exports = { + createRequestFlow, +}; diff --git a/server/src/services/verificationService/store.js b/server/src/services/verificationService/store.js new file mode 100644 index 00000000..b5d02062 --- /dev/null +++ b/server/src/services/verificationService/store.js @@ -0,0 +1,39 @@ +// Verification Store Module +// Purpose: Owns persisted verification-service store loading, normalization, and immutable cloning. +// Scope: Provides a stable load/write API so higher-level verification and moderation flows stay focused. +const { resolveDataPath } = require('../../helpers/dataPaths'); +const { createJsonStore } = require('../identityService'); +const logger = require('../../globals/logger').child('verificationService'); + +const STORE_PATH = resolveDataPath('verified-users.json'); + +function normalizeStoreShape(store) { + const next = store && typeof store === 'object' ? store : {}; + return { + verifiedUsers: Array.isArray(next.verifiedUsers) ? next.verifiedUsers : [], + pendingRequests: Array.isArray(next.pendingRequests) ? next.pendingRequests : [], + dmMessages: Array.isArray(next.dmMessages) ? next.dmMessages : [], + deterredUsers: Array.isArray(next.deterredUsers) ? next.deterredUsers : [], + }; +} + +function cloneStore(current) { + return { + verifiedUsers: (current.verifiedUsers || []).map((entry) => ({ ...entry, knownIps: [...(entry.knownIps || [])] })), + pendingRequests: (current.pendingRequests || []).map((entry) => ({ ...entry })), + dmMessages: (current.dmMessages || []).map((entry) => ({ ...entry })), + deterredUsers: (current.deterredUsers || []).map((entry) => ({ ...entry, knownIps: [...(entry.knownIps || [])] })), + }; +} + +const storeApi = createJsonStore({ + path: STORE_PATH, + normalizeStoreShape, + cloneStore, + logger, +}); + +module.exports = { + loadStore: storeApi.loadStore, + withStore: storeApi.withStore, +}; diff --git a/server/src/services/verificationService/verificationFlow.js b/server/src/services/verificationService/verificationFlow.js new file mode 100644 index 00000000..107be65f --- /dev/null +++ b/server/src/services/verificationService/verificationFlow.js @@ -0,0 +1,194 @@ +// Verification Flow Module +// Purpose: Implements verified-user matching, socket verification state reevaluation, and verified roster operations. +// Scope: Owns all behavior related to who is considered verified and how verified records are listed or removed. +function createVerificationFlow(deps) { + const { + loadStore, + withStore, + io, + publishEvent, + emitChange, + getRole, + ensureSocketData, + identityFromSocket, + normalizeCookieUserId, + sanitizeNickname, + getNickname, + reevaluateSocketDeterrence, + } = deps; + + function findVerifiedMatch(store, { cookieUserId, ip }) { + if (!cookieUserId && !ip) return null; + const byCookie = cookieUserId + ? store.verifiedUsers.find((entry) => normalizeCookieUserId(entry.cookieUserId) === cookieUserId) || null + : null; + if (byCookie) return byCookie; + if (!ip) return null; + return store.verifiedUsers.find((entry) => Array.isArray(entry.knownIps) && entry.knownIps.includes(ip)) || null; + } + + function reevaluateSocketVerification(socket) { + if (!socket) return { isVerified: false, matchedRecordId: null, reason: 'missing_socket' }; + const store = loadStore(); + const data = ensureSocketData(socket); + const role = getRole(socket); + const { cookieUserId, nickname, ip } = identityFromSocket(socket); + + if (role === 'lockdown') { + data.isVerified = true; + data.verifiedRecordId = null; + return { + isVerified: true, + matchedRecordId: null, + reason: 'lockdown_admin', + cookieUserId, + nickname, + ip, + }; + } + + const match = findVerifiedMatch(store, { cookieUserId, ip }); + const nicknameMatches = Boolean(match && nickname && sanitizeNickname(match.nickname) === nickname); + + let isVerified = false; + let reason = 'no_match'; + if (match && nicknameMatches) { + isVerified = true; + reason = 'matched'; + } else if (match && !nicknameMatches) { + reason = 'nickname_mismatch'; + } + + data.isVerified = isVerified; + data.verifiedRecordId = isVerified ? match.id : null; + + if (isVerified) { + withStore((draft) => { + const record = draft.verifiedUsers.find((entry) => entry.id === match.id); + if (!record) return; + record.updatedAt = Date.now(); + record.nickname = nickname; + if (ip && !record.knownIps.includes(ip)) { + record.knownIps.push(ip); + } + }); + } + + return { + isVerified, + matchedRecordId: isVerified ? match.id : null, + reason, + cookieUserId, + nickname, + ip, + }; + } + + function getVerificationStatus(socket) { + const data = socket?.data || {}; + return { + isVerified: Boolean(data.isVerified), + recordId: data.verifiedRecordId || null, + }; + } + + function getIdentitySummary(socket) { + const data = socket?.data || {}; + return { + cookieUserId: normalizeCookieUserId(data.cookieUserId) || null, + nickname: getNickname(socket) || null, + }; + } + + function listVerifiedUsers() { + const store = loadStore(); + return store.verifiedUsers.map((entry) => ({ ...entry, knownIps: [...(entry.knownIps || [])] })); + } + + function resolveVerifiedUserSelector(selector) { + const value = String(selector || '').trim(); + if (!value) return { error: 'selector_required' }; + const store = loadStore(); + const byCookie = store.verifiedUsers.find((entry) => entry.cookieUserId === value) || null; + if (byCookie) return { record: byCookie }; + const byNickname = store.verifiedUsers.filter((entry) => sanitizeNickname(entry.nickname) === sanitizeNickname(value)); + if (byNickname.length === 1) return { record: byNickname[0] }; + if (byNickname.length > 1) return { error: 'ambiguous_nickname' }; + return { error: 'not_found' }; + } + + function removeVerifiedUser(selector, removedBy = null) { + const resolved = resolveVerifiedUserSelector(selector); + if (resolved.error) { + throw new Error( + resolved.error === 'ambiguous_nickname' + ? 'Nickname matches multiple users; remove by cookieUserId.' + : 'Verified user not found.', + ); + } + const target = resolved.record; + let removed = null; + withStore((draft) => { + const before = draft.verifiedUsers.length; + draft.verifiedUsers = draft.verifiedUsers.filter((entry) => entry.id !== target.id); + if (draft.verifiedUsers.length !== before) { + removed = target; + } + }); + if (!removed) { + throw new Error('Verified user not found.'); + } + + io.sockets.sockets.forEach((socket) => { + const data = ensureSocketData(socket); + if (normalizeCookieUserId(data.cookieUserId) === removed.cookieUserId) { + reevaluateSocketVerification(socket); + reevaluateSocketDeterrence(socket); + } + }); + + emitChange('remove', { cookieUserId: removed.cookieUserId }); + publishEvent({ + source: 'verification', + type: 'verification.userRemoved', + payload: { + cookieUserId: removed.cookieUserId, + nickname: removed.nickname, + removedBy, + removedAt: Date.now(), + }, + }); + + return removed; + } + + function getVerificationStateForSocket(socket, getPendingRequestForIdentity) { + const identity = getIdentitySummary(socket); + const pending = getPendingRequestForIdentity(identity.cookieUserId); + return { + isVerified: Boolean(socket?.data?.isVerified), + pendingRequestId: pending?.id || null, + pendingRequestedAt: pending?.createdAt || null, + }; + } + + function isVerified(socket) { + return Boolean(socket?.data?.isVerified); + } + + return { + findVerifiedMatch, + reevaluateSocketVerification, + getVerificationStatus, + getIdentitySummary, + listVerifiedUsers, + resolveVerifiedUserSelector, + removeVerifiedUser, + getVerificationStateForSocket, + isVerified, + }; +} + +module.exports = { + createVerificationFlow, +}; diff --git a/server/src/services/videoAuthService/httpRoute.js b/server/src/services/videoAuthService/httpRoute.js new file mode 100644 index 00000000..7c78ea35 --- /dev/null +++ b/server/src/services/videoAuthService/httpRoute.js @@ -0,0 +1,74 @@ +// Video Auth HTTP Route +// Purpose: Wires the MediaMTX auth endpoint to session validation, audit logging, and policy checks. +// Scope: Handles request transport and token/session lookup while delegating stream parsing and auth decisions. +function registerVideoAuthRoute(deps) { + const { + app, + io, + logger, + videoSessions, + getRequestIp, + logAdminEvent, + extractStreamInfoFromBody, + canAccessStream, + } = deps; + + app.post('/mediamtx/auth', (req, res) => { + const body = req.body || {}; + const path = (body.path || '').replace(/^\//, ''); + const sessionId = body.user; + const action = (body.action || '').toLowerCase(); + const protocol = (body.protocol || '').toLowerCase(); + const ip = getRequestIp(req, body.ip); + const streamInfo = extractStreamInfoFromBody(body); + + logger.info('video auth request', { path: body.path, sessionId, stream: streamInfo, action, protocol }); + if (ip) { + logAdminEvent({ + label: 'mediamtx', + message: 'Media auth request', + ip, + meta: { path: body.path, sessionId, stream: streamInfo, action, protocol }, + }); + } + + const isSrtLikeProtocol = protocol === 'srt' || protocol === 'srtconn' || protocol.startsWith('srt'); + const isForwardAudioRead = action === 'read' && streamInfo?.id?.endsWith('-fwd'); + if ((action === 'read' && isSrtLikeProtocol) || isForwardAudioRead) { + return res.status(200).end(); + } + if (action === 'publish' && isSrtLikeProtocol) { + return res.status(200).end(); + } + + if (!sessionId || !streamInfo?.id) { + logger.warn('auth missing session or stream (session=%s path=%s)', sessionId, path); + return res.status(401).end(); + } + + const info = videoSessions.getSession(sessionId); + const streamTypeMatches = + info && + (info.sourceType === streamInfo.type || (info.sourceType === 'roverMic' && streamInfo.type === 'rover')); + if (!info || !streamTypeMatches || info.sourceId !== streamInfo.id) { + logger.warn('invalid session %s for stream %s:%s', sessionId, streamInfo.type, streamInfo.id); + return res.status(401).end(); + } + + const socket = io.sockets.sockets.get(info.socketId); + if (!socket) { + videoSessions.revokeSession(sessionId); + return res.status(401).end(); + } + + if (!canAccessStream({ socket, streamInfo, action, sourceType: info.sourceType })) { + return res.status(401).end(); + } + + return res.status(200).end(); + }); +} + +module.exports = { + registerVideoAuthRoute, +}; diff --git a/server/src/services/videoAuthService/index.js b/server/src/services/videoAuthService/index.js index d3393482..860c877d 100644 --- a/server/src/services/videoAuthService/index.js +++ b/server/src/services/videoAuthService/index.js @@ -1,6 +1,6 @@ -// video Auth Service -// Purpose: Defines the video Auth Service module and the helpers/state used by this service unit. -// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary. +// Video Auth Service Module +// Purpose: Composes MediaMTX stream parsing, authorization policy, and HTTP route registration. +// Scope: Exposes the video-auth service boundary while keeping runtime behavior unchanged. const { app } = require('../../globals/http'); const io = require('../../globals/io'); const logger = require('../../globals/logger').child('videoAuth'); @@ -10,195 +10,35 @@ const { isAdmin, isLockdownAdmin, getRole } = require('../roleService'); const { isVerified } = require('../verificationService'); const turnService = require('../turnService'); const roverManager = require('../roverManager'); -const { loadConfig } = require('../../helpers/configLoader'); const { getRequestIp, getSocketIp, isLocalNetwork } = require('../../helpers/ipResolver'); const { logAdminEvent } = require('../adminLogService'); -const config = loadConfig(); -const mediaConfig = config.media || {}; +const { extractStreamInfoFromBody } = require('./streamParsing'); +const { createVideoAuthPolicy } = require('./policy'); +const { registerVideoAuthRoute } = require('./httpRoute'); -function getPathPrefix() { - const base = mediaConfig.whepBaseUrl; - if (!base) return ''; - try { - const parsed = new URL(base); - return parsed.pathname || ''; - } catch { - return base.replace(/^[^/]*:\/\//, '').replace(/^[^/]+/, ''); - } -} - -const whepPathPrefix = getPathPrefix().replace(/\/+$/, '').replace(/^\/+/, ''); -const whepPrefixSegments = whepPathPrefix ? whepPathPrefix.split('/').filter(Boolean) : []; - -function extractStreamInfo(path) { - const segments = (path || '').split('/').filter(Boolean); - if (!segments.length) { - return null; - } - - let start = 0; - if ( - whepPrefixSegments.length && - whepPrefixSegments.every((segment, idx) => segments[idx] === segment) - ) { - start = whepPrefixSegments.length; - } - - let end = segments.length; - if (segments[end - 1] === 'whep' || segments[end - 1] === 'whip') { - end -= 1; - } - - const remaining = segments.slice(start, end); - if (remaining.length === 1) { - const rawId = remaining[0] || ''; - if (rawId.endsWith('-fwd')) { - return { type: 'rover', id: rawId, baseId: rawId.slice(0, -4) }; - } - const baseId = rawId.endsWith('-audio') ? rawId.slice(0, -6) : rawId; - return { type: 'rover', id: rawId, baseId }; - } - if (remaining.length === 2 && remaining[0] === 'room') { - return { type: 'room', id: remaining[1] || '' }; - } - return null; -} - -function extractSrtStreamId(rawValue) { - const value = decodeURIComponent(String(rawValue || '').trim()); - if (!value) return ''; - - // streamid may be passed as the full value or as query text. - const match = value.match(/(?:^|[?&]|,|#!::)r=([^,&]+)/); - if (match?.[1]) { - return match[1]; - } - - // Fallback: treat plain token as stream id when no separators are present. - if (!/[?&=,:]/.test(value)) { - return value; - } - return ''; -} - -function extractStreamInfoFromBody(body = {}) { - const fromPath = extractStreamInfo((body.path || '').replace(/^\//, '')); - if (fromPath) return fromPath; - - const srtId = - extractSrtStreamId(body.streamid) || - extractSrtStreamId(body.streamId) || - extractSrtStreamId(body.query); - if (!srtId) return null; - - if (srtId.endsWith('-fwd')) { - return { type: 'rover', id: srtId, baseId: srtId.slice(0, -4) }; - } - const baseId = srtId.endsWith('-audio') ? srtId.slice(0, -6) : srtId; - return { type: 'rover', id: srtId, baseId }; -} - -function canView(socket) { - const mode = getMode(); - if (!socket) { - return false; - } - if (mode === MODES.LOCKDOWN) { - return isLockdownAdmin(socket); - } - if (mode === MODES.ADMIN) { - const role = getRole(socket); - return role === 'spectator' || isAdmin(socket); - } - return true; -} - -app.post('/mediamtx/auth', (req, res) => { - const body = req.body || {}; - const path = (body.path || '').replace(/^\//, ''); - const sessionId = body.user; - const action = (body.action || '').toLowerCase(); - const protocol = (body.protocol || '').toLowerCase(); - const ip = getRequestIp(req, body.ip); - const streamInfo = extractStreamInfoFromBody(body); - - logger.info('video auth request', { path: body.path, sessionId, stream: streamInfo, action, protocol }); - if (ip) { - logAdminEvent({ - label: 'mediamtx', - message: 'Media auth request', - ip, - meta: { path: body.path, sessionId, stream: streamInfo, action, protocol }, - }); - } - - const isSrtLikeProtocol = protocol === 'srt' || protocol === 'srtconn' || protocol.startsWith('srt'); - const isForwardAudioRead = action === 'read' && streamInfo?.id?.endsWith('-fwd'); - // Rover forward-listener uses SRT read without session tokens; allow these reads. - if ((action === 'read' && isSrtLikeProtocol) || isForwardAudioRead) { - return res.status(200).end(); - } - // Existing rover/media publishers use SRT without per-session tokens. - if (action === 'publish' && isSrtLikeProtocol) { - return res.status(200).end(); - } - - if (!sessionId || !streamInfo?.id) { - logger.warn('auth missing session or stream (session=%s path=%s)', sessionId, path); - return res.status(401).end(); - } - - const info = videoSessions.getSession(sessionId); - const streamTypeMatches = - info && - (info.sourceType === streamInfo.type || (info.sourceType === 'roverMic' && streamInfo.type === 'rover')); - if (!info || !streamTypeMatches || info.sourceId !== streamInfo.id) { - logger.warn('invalid session %s for stream %s:%s', sessionId, streamInfo.type, streamInfo.id); - return res.status(401).end(); - } - const socket = io.sockets.sockets.get(info.socketId); - if (!socket) { - videoSessions.revokeSession(sessionId); - return res.status(401).end(); - } - if (!canView(socket)) { - return res.status(401).end(); - } - if (streamInfo.type === 'rover') { - const roverId = streamInfo.baseId || streamInfo.id; - if (!roverManager.canSeeRover(roverId, socket)) { - return res.status(401).end(); - } - } - if (info.sourceType === 'roverMic' && action === 'publish') { - const roverId = streamInfo.baseId || streamInfo.id; - if (!isVerified(socket)) { - return res.status(401).end(); - } - if (!roverManager.isDriver(roverId, socket)) { - return res.status(401).end(); - } - if (!turnService.canDrive(roverId, socket)) { - return res.status(401).end(); - } - return res.status(200).end(); - } - - const role = getRole(socket); - const isAudio = streamInfo.id?.endsWith('-audio'); - if (role === 'spectator' && !isAdmin(socket) && !isAudio) { - const socketIp = getSocketIp(socket); - if (!isLocalNetwork(socketIp)) { - return res.status(401).end(); - } - } - if (streamInfo.type === 'rover' && role !== 'spectator' && !isAdmin(socket)) { - const roverId = streamInfo.baseId || streamInfo.id; - if (!roverManager.isDriver(roverId, socket)) { - return res.status(401).end(); - } - } - - return res.status(200).end(); +const { canAccessStream } = createVideoAuthPolicy({ + getMode, + MODES, + isAdmin, + isLockdownAdmin, + getRole, + isVerified, + turnService, + roverManager, + getSocketIp, + isLocalNetwork, }); + +registerVideoAuthRoute({ + app, + io, + logger, + videoSessions, + getRequestIp, + logAdminEvent, + extractStreamInfoFromBody, + canAccessStream, +}); + +module.exports = {}; diff --git a/server/src/services/videoAuthService/policy.js b/server/src/services/videoAuthService/policy.js new file mode 100644 index 00000000..7946218b --- /dev/null +++ b/server/src/services/videoAuthService/policy.js @@ -0,0 +1,83 @@ +// Video Auth Policy +// Purpose: Encapsulates mode, role, and stream-specific authorization decisions for MediaMTX auth checks. +// Scope: Evaluates viewer/publisher eligibility from normalized request context and socket/session state. +function createVideoAuthPolicy(deps) { + const { + getMode, + MODES, + isAdmin, + isLockdownAdmin, + getRole, + isVerified, + turnService, + roverManager, + getSocketIp, + isLocalNetwork, + } = deps; + + function canView(socket) { + const mode = getMode(); + if (!socket) return false; + if (mode === MODES.LOCKDOWN) { + return isLockdownAdmin(socket); + } + if (mode === MODES.ADMIN) { + const role = getRole(socket); + return role === 'spectator' || isAdmin(socket); + } + return true; + } + + function canAccessStream({ socket, streamInfo, action, sourceType }) { + if (!canView(socket)) { + return false; + } + + if (streamInfo.type === 'rover') { + const roverId = streamInfo.baseId || streamInfo.id; + if (!roverManager.canSeeRover(roverId, socket)) { + return false; + } + } + + if (sourceType === 'roverMic' && action === 'publish') { + const roverId = streamInfo.baseId || streamInfo.id; + if (!isVerified(socket)) { + return false; + } + if (!roverManager.isDriver(roverId, socket)) { + return false; + } + if (!turnService.canDrive(roverId, socket)) { + return false; + } + return true; + } + + const role = getRole(socket); + const isAudio = streamInfo.id?.endsWith('-audio'); + if (role === 'spectator' && !isAdmin(socket) && !isAudio) { + const socketIp = getSocketIp(socket); + if (!isLocalNetwork(socketIp)) { + return false; + } + } + + if (streamInfo.type === 'rover' && role !== 'spectator' && !isAdmin(socket)) { + const roverId = streamInfo.baseId || streamInfo.id; + if (!roverManager.isDriver(roverId, socket)) { + return false; + } + } + + return true; + } + + return { + canAccessStream, + }; +} + +module.exports = { + createVideoAuthPolicy, +}; diff --git a/server/src/services/videoAuthService/streamParsing.js b/server/src/services/videoAuthService/streamParsing.js new file mode 100644 index 00000000..74aad79e --- /dev/null +++ b/server/src/services/videoAuthService/streamParsing.js @@ -0,0 +1,90 @@ +// Video Auth Stream Parsing +// Purpose: Parses MediaMTX path/body payloads into normalized stream targets for rover and room media checks. +// Scope: Handles WHEP/WHP path-prefix trimming and SRT streamid extraction without performing auth decisions. +const { loadConfig } = require('../../helpers/configLoader'); + +const config = loadConfig(); +const mediaConfig = config.media || {}; + +function getPathPrefix() { + const base = mediaConfig.whepBaseUrl; + if (!base) return ''; + try { + const parsed = new URL(base); + return parsed.pathname || ''; + } catch { + return base.replace(/^[^/]*:\/\//, '').replace(/^[^/]+/, ''); + } +} + +const whepPathPrefix = getPathPrefix().replace(/\/+$/, '').replace(/^\/+/, ''); +const whepPrefixSegments = whepPathPrefix ? whepPathPrefix.split('/').filter(Boolean) : []; + +function extractStreamInfo(path) { + const segments = (path || '').split('/').filter(Boolean); + if (!segments.length) return null; + + let start = 0; + if (whepPrefixSegments.length && whepPrefixSegments.every((segment, idx) => segments[idx] === segment)) { + start = whepPrefixSegments.length; + } + + let end = segments.length; + if (segments[end - 1] === 'whep' || segments[end - 1] === 'whip') { + end -= 1; + } + + const remaining = segments.slice(start, end); + if (remaining.length === 1) { + const rawId = remaining[0] || ''; + if (rawId.endsWith('-fwd')) { + return { type: 'rover', id: rawId, baseId: rawId.slice(0, -4) }; + } + const baseId = rawId.endsWith('-audio') ? rawId.slice(0, -6) : rawId; + return { type: 'rover', id: rawId, baseId }; + } + + if (remaining.length === 2 && remaining[0] === 'room') { + return { type: 'room', id: remaining[1] || '' }; + } + + return null; +} + +function extractSrtStreamId(rawValue) { + const value = decodeURIComponent(String(rawValue || '').trim()); + if (!value) return ''; + + const match = value.match(/(?:^|[?&]|,|#!::)r=([^,&]+)/); + if (match?.[1]) { + return match[1]; + } + + if (!/[?&=,:]/.test(value)) { + return value; + } + + return ''; +} + +function extractStreamInfoFromBody(body = {}) { + const fromPath = extractStreamInfo((body.path || '').replace(/^\//, '')); + if (fromPath) return fromPath; + + const srtId = + extractSrtStreamId(body.streamid) || + extractSrtStreamId(body.streamId) || + extractSrtStreamId(body.query); + if (!srtId) return null; + + if (srtId.endsWith('-fwd')) { + return { type: 'rover', id: srtId, baseId: srtId.slice(0, -4) }; + } + + const baseId = srtId.endsWith('-audio') ? srtId.slice(0, -6) : srtId; + return { type: 'rover', id: srtId, baseId }; +} + +module.exports = { + extractStreamInfoFromBody, +};