// Thin wrapper around Roblox's public Users API for resolving usernames <-> user ids. These // endpoints require no authentication, so we just use the runtime's built-in fetch() - same // pattern as lib/webhooks.js and the group-role lookup in routes/api/v1/ingame.js - rather than // pulling in the heavier noblox.js package (which is mainly aimed at cookie-authenticated game // automation, not simple id/username lookups). // // All lookups are cached in-memory (including negative/not-found results) for CACHE_TTL_MS, both // to keep the dashboard snappy when the same ids show up repeatedly (ACL entries, access group // members, scan logs) and to avoid tripping Roblox's rate limits. const USERNAME_TO_ID_URL = 'https://users.roblox.com/v1/usernames/users'; const IDS_TO_USERS_URL = 'https://users.roblox.com/v1/users'; const singleUserUrl = (id) => `https://users.roblox.com/v1/users/${id}`; const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes const byId = new Map(); // id (string) -> { value: {id,name,displayName} | null, expires } const byUsername = new Map(); // lowercased username -> same shape function getCached(map, key) { const entry = map.get(key); if (!entry) return undefined; if (Date.now() > entry.expires) { map.delete(key); return undefined; } return entry.value; } function setCached(map, key, value) { map.set(key, { value, expires: Date.now() + CACHE_TTL_MS }); } // Caches a resolved user under both its id and username, so a lookup by either later hits cache. function cacheUser(user) { setCached(byId, String(user.id), user); setCached(byUsername, user.name.toLowerCase(), user); } // Splits an array into chunks of at most `size` items (Roblox's bulk endpoints are happy with // large batches, but we chunk defensively so one call can't grow unbounded). function chunk(arr, size) { const out = []; for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size)); return out; } // Looks up a single Roblox user by username. Returns { id, name, displayName } or null if no such // user exists (or the lookup failed). Both hits and misses are cached. async function getUserByUsername(username) { const key = String(username || '').trim().toLowerCase(); if (!key) return null; const cached = getCached(byUsername, key); if (cached !== undefined) return cached; try { const res = await fetch(USERNAME_TO_ID_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ usernames: [username], excludeBannedUsers: false }) }); if (!res.ok) throw new Error(`Roblox responded with ${res.status}`); const body = await res.json(); const match = (body.data || [])[0]; const value = match ? { id: match.id, name: match.name, displayName: match.displayName } : null; setCached(byUsername, key, value); if (value) setCached(byId, String(value.id), value); return value; } catch (err) { console.error('Roblox username lookup failed:', err.message); return null; } } // Looks up a single Roblox user by id. Returns { id, name, displayName } or null. async function getUserById(userId) { const key = String(userId || '').trim(); if (!key || Number.isNaN(Number(key))) return null; const cached = getCached(byId, key); if (cached !== undefined) return cached; try { const res = await fetch(singleUserUrl(key)); if (res.status === 404) { setCached(byId, key, null); return null; } if (!res.ok) throw new Error(`Roblox responded with ${res.status}`); const body = await res.json(); const value = { id: body.id, name: body.name, displayName: body.displayName }; cacheUser(value); return value; } catch (err) { console.error('Roblox user id lookup failed:', err.message); return null; } } // Batch-resolves multiple Roblox user ids to usernames at once (e.g. for a whole page of ACL // entries or scan logs), to avoid one request per row. Returns a plain object mapping id (string) // -> { id, name, displayName }; ids that don't resolve are simply omitted from the result. async function getUsersByIds(userIds) { const ids = [...new Set((userIds || []).map(id => String(id).trim()).filter(id => id && !Number.isNaN(Number(id))))]; const result = {}; const uncached = []; for (const id of ids) { const cached = getCached(byId, id); if (cached !== undefined) { if (cached) result[id] = cached; } else { uncached.push(id); } } for (const batch of chunk(uncached, 100)) { try { const res = await fetch(IDS_TO_USERS_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userIds: batch.map(Number), excludeBannedUsers: false }) }); if (!res.ok) throw new Error(`Roblox responded with ${res.status}`); const body = await res.json(); const found = new Set(); for (const user of body.data || []) { const value = { id: user.id, name: user.name, displayName: user.displayName }; cacheUser(value); result[String(user.id)] = value; found.add(String(user.id)); } // Cache misses too, so we don't keep re-requesting ids that don't exist. for (const id of batch) { if (!found.has(id)) setCached(byId, id, null); } } catch (err) { console.error('Roblox batch user lookup failed:', err.message); } } return result; } module.exports = { getUserByUsername, getUserById, getUsersByIds };