feat(rust): the live map (phase 14, protocol 11)
All checks were successful
PR Checks / server-tests (pull_request) Successful in 28s
PR Checks / client-build (pull_request) Successful in 27s
PR Checks / frozen-manifest (pull_request) Successful in -1m9s

PLAN.md §30 as approved, plus D119/D120 from the build.

Server:
- rust_map_images (one row per server: picture as MEDIUMBLOB, geometry,
  monuments, DERIVATION_VERSION) and rust_map_overrides; purge.sql pair.
- mapImages.js: D110. The board poll notices a new boot/wipe/seed/size and
  asks map.info; a new key or hash from the free Rust+ cache (or a render
  kept on disk) is fetched in slices, checked against its SHA-256 and stored
  in one statement. One fetch per server, a backoff on failure, `stale`
  abandons a fetch that straddles a map change. Render now (D109) is
  admin-only and watched to completion.
- mapLive.js: D111. One map.live per server per 5 s whoever asks; positions
  are held in memory only.
- model/map: four layers (world, events public; players, bases staff), a
  fleet default plus per-server override (D114), the players layer capped by
  presence (D113), own dot and online first-party clan mates for a linked
  viewer (D115, D117, D118). A layer the viewer may not see is absent from
  the answer, never sent and hidden.
- Routes: public /servers/:id/map, /map/image (immutable under its hash),
  /map/live; admin /servers/:id/map/fetch and /render; the Map card on the
  visibility PUT. Swagger fragment and frozen manifest regenerated.

Client:
- A Map tab: Leaflet over the picture in CRS.Simple, the game's own grid
  (labels only when a cell is wide enough to hold one), a legend that lists
  hidden layers with who can see them, polled every 10 s while visible.
- D120: Leaflet is a lazy split chunk beside entry.js, not in it. release.yml
  copies every dist/*.js; checkExternals and build.test.js hold both ends.
- The Map card on Admin -> Rust visibility, with Fetch again and Render now.

Capability `map` declared for the Android app (phase 15).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-09-25 01:06:10 -05:00
parent ac0bcd850a
commit 0cb9bdd1f0
34 changed files with 4680 additions and 28 deletions

147
server/model/map/map.db.js Normal file
View File

@@ -0,0 +1,147 @@
// ── SQL for the map ───────────────────────────────────────────────────────
//
// Three things: the picture each server's map is drawn on (`rust_map_images`),
// the per-server overrides of the map's switches (`rust_map_overrides`), and the
// two reads the own-and-mates view needs — which Steam accounts a website user
// holds, and who shares a clan with them on one server.
//
// Nothing here stores a POSITION. Where people are is asked for while somebody
// is looking and kept in memory for seconds (D111, `mapLive.js`).
const core = require('../../core')
const IMAGES = 'rust_map_images'
const OVERRIDES = 'rust_map_overrides'
const LINKS = 'rust_account_links'
const CLANS = 'rust_clans'
const MEMBERS = 'rust_clan_members'
/** The columns every read but the picture's own wants — everything except the bytes. */
const META = `server_id AS serverId, map_key AS mapKey, sha256, source, width, height,
ocean_margin AS oceanMargin, world_size AS worldSize, grid_cells AS gridCells,
grid_cell_size AS gridCellSize, background, derivation, monuments,
OCTET_LENGTH(bytes) AS byteCount, fetched_at AS fetchedAt`
/** One server's picture row without the bytes, or undefined. */
async function getMeta(serverId) {
const rows = await core.query(`SELECT ${META} FROM ${IMAGES} WHERE server_id = ?`, [serverId])
return rows[0]
}
/** Every server's picture row without the bytes, for the admin card. */
async function listMeta() {
return core.query(`SELECT ${META} FROM ${IMAGES}`)
}
/**
* The picture itself, but only if it is still the one named. A URL carries the
* hash it was minted for, and a picture replaced since must not be served under
* it: the response is cached as immutable.
*/
async function getBytes(serverId, sha256) {
const rows = await core.query(
`SELECT bytes FROM ${IMAGES} WHERE server_id = ? AND sha256 = ? AND bytes IS NOT NULL`,
[serverId, sha256],
)
return rows[0] ? rows[0].bytes : null
}
/**
* Replace one server's row whole, in ONE statement. A new map's picture and its
* geometry arrive together or not at all; a reader never sees the new bytes
* under the old monuments.
*/
async function putImage(row) {
await core.query(
`REPLACE INTO ${IMAGES}
(server_id, map_key, sha256, source, width, height, ocean_margin, world_size,
grid_cells, grid_cell_size, background, derivation, monuments, bytes, fetched_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`,
[
row.serverId, row.mapKey, row.sha256 || null, row.source, row.width, row.height, row.oceanMargin,
row.worldSize, row.gridCells, row.gridCellSize, row.background || null, row.derivation,
JSON.stringify(row.monuments || []), row.bytes || null,
],
)
}
/**
* Re-derive a row's geometry and monuments without touching its picture — the
* `DERIVATION_VERSION` path, and a map whose picture is unchanged but whose
* source moved (a render replaced by the Rust+ cache of the same bytes).
*/
async function putGeometry(row) {
await core.query(
`UPDATE ${IMAGES}
SET source = ?, width = ?, height = ?, ocean_margin = ?, world_size = ?, grid_cells = ?,
grid_cell_size = ?, background = ?, derivation = ?, monuments = ?
WHERE server_id = ? AND map_key = ?`,
[
row.source, row.width, row.height, row.oceanMargin, row.worldSize, row.gridCells, row.gridCellSize,
row.background || null, row.derivation, JSON.stringify(row.monuments || []), row.serverId, row.mapKey,
],
)
}
/** Every override for one server, as `{ setting: value }` rows. */
async function getOverrides(serverId) {
return core.query(`SELECT setting, value FROM ${OVERRIDES} WHERE server_id = ?`, [serverId])
}
/** Every override in the fleet, for the admin card. */
async function listOverrides() {
return core.query(`SELECT server_id AS serverId, setting, value FROM ${OVERRIDES}`)
}
/** Set (a word) or clear (`null`) one server's override of one switch. */
async function setOverride(serverId, setting, value, userId = null) {
if (value === null) {
await core.query(`DELETE FROM ${OVERRIDES} WHERE server_id = ? AND setting = ?`, [serverId, setting])
return
}
await core.query(
`INSERT INTO ${OVERRIDES} (server_id, setting, value, updated_by, updated_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE value = VALUES(value), updated_by = VALUES(updated_by),
updated_at = CURRENT_TIMESTAMP`,
[serverId, setting, value, userId],
)
}
/** Every Steam id one website user has linked. A link reaches every server (D28). */
async function steamIdsForUser(userId) {
const rows = await core.query(`SELECT steam_id AS steamId FROM ${LINKS} WHERE user_id = ?`, [userId])
return rows.map((r) => String(r.steamId))
}
/**
* Every Steam id sharing a first-party clan with any of `steamIds` on ONE
* server (D115, D117) — the viewer's own accounts included, since they are
* members too. A clan the board no longer carries (`gone_at`) is nobody's clan.
*/
async function clanMatesOn(serverId, steamIds) {
if (!steamIds.length) return []
const rows = await core.query(
`SELECT DISTINCT m2.steam_id AS steamId
FROM ${MEMBERS} m1
JOIN ${CLANS} c ON c.external_id = m1.external_id
JOIN ${MEMBERS} m2 ON m2.external_id = m1.external_id
WHERE c.server_id = ? AND c.gone_at IS NULL
AND m1.steam_id IN (${steamIds.map(() => '?').join(', ')})`,
[serverId, ...steamIds],
)
return rows.map((r) => String(r.steamId))
}
module.exports = {
getMeta,
listMeta,
getBytes,
putImage,
putGeometry,
getOverrides,
listOverrides,
setOverride,
steamIdsForUser,
clanMatesOn,
}

View File

@@ -0,0 +1,414 @@
// ── The map: who may see which layer, and what a viewer is sent ───────────
//
// R9's security boundary, and the reason this file exists apart from the
// picture machinery in `mapImages.js`: **public player positions in Rust locate
// players, and base positions are where they sleep.** So every layer has its own
// audience (D112), a fleet default with a per-server override (D114), and a
// viewer is SENT only the layers they may see — a hidden layer is absent from the
// answer, never present and hidden by the page (§30.2).
//
// ── The four layers ───────────────────────────────────────────────────────
//
// world monuments, and the world's own events: cargo, the patrol
// helicopter, the Chinook, Bradley, supply drops, locked crates
// events what this site's events placed: zones, crates, NPCs (phase 13a)
// players who is on the server and where, and the sleepers
// bases tool cupboards and player vending machines, as positions only
//
// Defaults: the first two public, the last two staff. The audiences are the
// presence rungs (`staff` · `signed_in` · `public`) and follow its asymmetric
// fallbacks: an unknown stored word narrows to staff, an unknown viewer is
// public (`model/visibility`).
//
// ── Two rules that make the players layer safe to widen ───────────────────
//
// **D113 — it can never show more than presence does.** Its effective audience
// is the NARROWER of the layer's switch and the server's presence audience. An
// operator who opens the map to the public while the roll call stays staff has
// opened nothing: a name on a map says who is online as surely as a list does.
//
// **D115/D117 — own dot and clan mates.** A linked viewer sees their own
// position (their sleeper too, §30.5) and their ONLINE first-party clan mates on
// that server, whatever the players layer says — and only a linked member of the
// same clan sees them. It is gated by its own switch (D118, default on) and by
// nothing else: widening the roster widens who sees the member LIST, never where
// the members are.
const core = require('../../core')
const db = require('./map.db')
const visibility = require('../visibility/visibility.model')
const visibilityDb = require('../visibility/visibility.db')
const log = core.logger('map')
const LAYERS = Object.freeze(['world', 'events', 'players', 'bases'])
const DEFAULTS = Object.freeze({ world: 'public', events: 'public', players: 'staff', bases: 'staff' })
/** D118: on, because it shows a member nothing the game does not already show them. */
const DEFAULT_MATES = true
const MATES_KEY = 'map.mates'
const layerKey = (layer) => `map.layer.${layer}.audience`
/** Every override setting name a server may carry. */
const SETTINGS = Object.freeze([...LAYERS.map(layerKey), MATES_KEY])
/**
* `DERIVATION_VERSION` (R9): how a row's geometry is worked out from what the
* plugin said. A stored row with an older number is re-derived from a fresh
* `map.info`, without fetching the picture again.
*
* 1 — width/height from the picture itself (or, with no picture, the Rust+
* cache's geometry: half scale plus the ocean margin); the grid from the
* game's own `MapHelper` (D119); the margin in PIXELS, unscaled.
*/
const DERIVATION_VERSION = 1
/** The Rust+ cache's scale, used only to size a map that has no picture yet. */
const CACHE_SCALE = 0.5
/** How often the page asks for positions while visible, and how long the module keeps an answer. */
const POLL_MS = 10000
const LIVE_CACHE_MS = 5000
/**
* What a render costs, measured on the rig (§30.0): 8.5 s for a 3000 map at
* half scale, a 2500 × 2500 picture. The work is per pixel, so the estimate for
* another size scales with its area.
*/
const RENDER_MEASURED = Object.freeze({ worldSize: 3000, seconds: 8.5 })
function renderStallSeconds(worldSize, margin = 500) {
const side = (ws) => ws * CACHE_SCALE + 2 * margin
const size = Number(worldSize) > 0 ? Number(worldSize) : RENDER_MEASURED.worldSize
const ratio = (side(size) * side(size)) / (side(RENDER_MEASURED.worldSize) ** 2)
return Math.max(1, Math.round(RENDER_MEASURED.seconds * ratio))
}
/** A stored mates word as a boolean. Anything but `on` is off: an unknown word narrows. */
const matesOn = (value) => value === 'on'
/** The narrower of two audiences: the one fewer people satisfy. */
function narrower(a, b) {
const ra = visibility.AUDIENCES.indexOf(visibility.normalise(a))
const rb = visibility.AUDIENCES.indexOf(visibility.normalise(b))
return visibility.AUDIENCES[Math.max(ra, rb)]
}
/** The fleet defaults, as stored, with the built-in defaults where nothing is. */
async function fleet() {
const stored = await Promise.all([...LAYERS.map((l) => visibilityDb.getSetting(layerKey(l))), visibilityDb.getSetting(MATES_KEY)])
const out = {}
LAYERS.forEach((layer, i) => {
out[layer] = stored[i] == null ? DEFAULTS[layer] : visibility.normalise(stored[i])
})
const mates = stored[LAYERS.length]
out.mates = mates == null ? DEFAULT_MATES : matesOn(mates)
return out
}
/** Overrides rows as `{ world: 'staff', mates: false, … }`, only for what is set. */
function overridesFrom(rows) {
const out = {}
for (const { setting, value } of rows) {
if (setting === MATES_KEY) out.mates = matesOn(value)
else {
const layer = LAYERS.find((l) => layerKey(l) === setting)
if (layer) out[layer] = visibility.normalise(value)
}
}
return out
}
/** What applies to one server: its overrides over the fleet. */
async function forServer(serverId) {
const [base, rows] = await Promise.all([fleet(), db.getOverrides(serverId)])
return { ...base, ...overridesFrom(rows) }
}
/**
* Everything the public routes need to decide what one viewer gets on one
* server's map. Throws nothing: a setting that cannot be read hides every layer
* but the picture, which is the direction a map must fail in.
*/
async function access(req, serverId) {
try {
const [viewer, settings, presence] = await Promise.all([
visibility.viewer(req),
forServer(serverId),
visibility.presenceFor(serverId),
])
const layers = {}
for (const layer of LAYERS) {
const audience = layer === 'players' ? narrower(settings.players, presence) : settings[layer]
layers[layer] = { visible: visibility.meets(viewer.level, audience), audience }
}
// Why the players layer is narrower than its own switch, when it is (D113):
// the page says "limited by who may see who is online" rather than nothing.
if (layers.players.audience !== visibility.normalise(settings.players)) layers.players.cappedByPresence = true
let steamIds = []
if (settings.mates && viewer.userId != null) steamIds = await db.steamIdsForUser(viewer.userId)
const mates = {
on: settings.mates,
linked: steamIds.length > 0,
visible: settings.mates && steamIds.length > 0,
}
return { level: viewer.level, userId: viewer.userId, layers, mates, steamIds }
} catch (err) {
log.warn('could not resolve map visibility; showing the picture only', { server: serverId, error: err.message })
const layers = {}
for (const layer of LAYERS) layers[layer] = { visible: false, audience: 'staff' }
return { level: 'public', userId: null, layers, mates: { on: false, linked: false, visible: false }, steamIds: [] }
}
}
/**
* The positions a viewer who is entitled to own-and-mates may see: their own
* accounts (online or asleep) and their ONLINE clan mates on this server. Empty
* for anybody else. Asked only when there is a live answer to filter.
*/
async function mateIdsFor(serverId, acc) {
if (!acc.mates.visible || !acc.steamIds.length) return { own: new Set(), mates: new Set() }
const own = new Set(acc.steamIds)
const clan = await db.clanMatesOn(serverId, acc.steamIds)
return { own, mates: new Set(clan.filter((id) => !own.has(id))) }
}
/**
* One live answer, cut down to what one viewer may see. **Pure**, and the
* security boundary in one function: a layer the viewer may not see is not in
* the result at all — not an empty array, not a flag — so there is nothing on
* the wire for a page to forget to hide.
*
* `mates` is the viewer's own dots and their online clan mates' (D115). It is
* the ONLY place a player position can appear below the players layer, and it
* never carries anybody outside the viewer's clan.
*/
function project(live, acc, ids = { own: new Set(), mates: new Set() }) {
const out = { mapKey: live.mapKey || null, t: live.t || null }
if (acc.layers.world.visible) out.world = Array.isArray(live.world) ? live.world : []
if (acc.layers.events.visible) out.events = Array.isArray(live.events) ? live.events : []
if (acc.layers.players.visible) {
out.players = Array.isArray(live.players) ? live.players : []
if (live.playersTruncated) out.playersTruncated = true
}
if (acc.layers.bases.visible) {
out.bases = Array.isArray(live.bases) ? live.bases : []
if (live.basesTruncated) out.basesTruncated = true
}
if (acc.mates.visible) {
const players = Array.isArray(live.players) ? live.players : []
out.mates = players
.filter((p) => ids.own.has(String(p.steamId)) || (ids.mates.has(String(p.steamId)) && p.online === true))
.map((p) => ({
steamId: String(p.steamId),
name: p.name,
x: p.x,
z: p.z,
sleeping: Boolean(p.sleeping),
online: p.online === true,
self: ids.own.has(String(p.steamId)),
}))
}
return out
}
/**
* A stored row as the geometry the page draws with. The picture's own size is
* the truth when there is one; without one the Rust+ cache's geometry stands in,
* so a server that has no picture yet draws its layers in the same frame a
* picture would later fill.
*/
function geometryOf(row) {
if (!row) return null
return {
worldSize: Number(row.worldSize),
oceanMargin: Number(row.oceanMargin),
width: Number(row.width),
height: Number(row.height),
gridCells: Number(row.gridCells),
gridCellSize: Number(row.gridCellSize),
background: row.background || null,
}
}
/**
* `map.info` as the row it becomes (without bytes). **Pure** — the one place
* `DERIVATION_VERSION` is applied.
*/
function derive(serverId, info) {
const worldSize = Number(info.worldSize) || 0
const oceanMargin = Number(info.oceanMargin) || 0
const hasPicture = info.source !== 'none' && Number(info.width) > 0 && Number(info.height) > 0
const side = Math.round(worldSize * CACHE_SCALE + 2 * oceanMargin)
return {
serverId,
mapKey: String(info.mapKey || ''),
sha256: hasPicture && info.sha256 ? String(info.sha256).toLowerCase() : null,
source: hasPicture ? String(info.source) : 'none',
width: hasPicture ? Number(info.width) : side,
height: hasPicture ? Number(info.height) : side,
oceanMargin,
worldSize,
gridCells: Number(info.gridCells) || 1,
gridCellSize: Number(info.gridCellSize) || worldSize,
background: typeof info.background === 'string' && /^#[0-9a-f]{6}$/i.test(info.background) ? info.background : null,
derivation: DERIVATION_VERSION,
monuments: Array.isArray(info.monuments)
? info.monuments
.filter((m) => m && Number.isFinite(Number(m.x)) && Number.isFinite(Number(m.z)))
.map((m) => ({
value: String(m.value || ''),
kind: String(m.kind || ''),
label: String(m.label || m.kind || ''),
grid: m.grid ? String(m.grid) : null,
x: Number(m.x),
z: Number(m.z),
}))
: [],
}
}
/** Parse the stored monuments, never throwing: a row that will not parse draws none. */
function monumentsOf(row) {
if (!row || !row.monuments) return []
try {
const parsed = typeof row.monuments === 'string' ? JSON.parse(row.monuments) : row.monuments
return Array.isArray(parsed) ? parsed : []
} catch (err) {
return []
}
}
// ── Admin ────────────────────────────────────────────────────────────────
/** The Map card's switches: the fleet, and each server's overrides and effective values. */
async function describeSwitches(servers) {
const [base, rows] = await Promise.all([fleet(), db.listOverrides()])
const byServer = new Map()
for (const row of rows) {
if (!byServer.has(row.serverId)) byServer.set(row.serverId, [])
byServer.get(row.serverId).push(row)
}
return {
layers: [...LAYERS],
defaults: { ...DEFAULTS, mates: DEFAULT_MATES },
fleet: base,
servers: servers.map((s) => {
const overrides = overridesFrom(byServer.get(s.id) || [])
const full = {}
for (const key of [...LAYERS, 'mates']) full[key] = key in overrides ? overrides[key] : null
return { id: s.id, overrides: full, effective: { ...base, ...overrides } }
}),
}
}
/**
* The Map card's write, validated whole before anything is written, like the
* rest of the visibility page.
*
* { fleet: { world: 'public', …, mates: true },
* servers: { <id>: { players: 'signed_in', mates: null, … } } }
*
* `null` clears an override. Resolves `{ ok, changed }` or `{ ok: false,
* status, message }`. `dryRun` validates and writes nothing, so a caller saving
* several things in one request can refuse the whole request up front.
*/
async function update({ fleet: fleetIn, servers } = {}, actor = null, serverExists = async () => true, { dryRun = false } = {}) {
const fleetChanges = []
const serverChanges = []
const check = (key, value, where, allowNull) => {
if (value === null && allowNull) return null
if (key === 'mates') {
if (typeof value !== 'boolean') return `The own-and-mates view is on or off${where}, not "${value}".`
return null
}
if (!LAYERS.includes(key)) return `"${key}" is not a map layer. The layers are: ${LAYERS.join(', ')}, and mates.`
if (!visibility.isAudience(value)) {
return `"${value}" is not an audience for the ${key} layer${where}. Choose one of: ${visibility.AUDIENCES.join(', ')}.`
}
return null
}
for (const [key, value] of Object.entries(fleetIn || {})) {
const problem = check(key, value, '', false)
if (problem) return { ok: false, status: 400, message: problem }
fleetChanges.push([key, value])
}
for (const [id, settings] of Object.entries(servers || {})) {
if (!settings || typeof settings !== 'object') {
return { ok: false, status: 400, message: `Server ${id}'s map switches must be an object.` }
}
// eslint-disable-next-line no-await-in-loop
if (!(await serverExists(id))) return { ok: false, status: 404, message: `There is no server called ${id}.` }
for (const [key, value] of Object.entries(settings)) {
const problem = check(key, value, ` on server ${id}`, true)
if (problem) return { ok: false, status: 400, message: problem }
serverChanges.push([id, key, value])
}
}
if (dryRun) return { ok: true, changed: {} }
const userId = actor && actor.id != null ? actor.id : null
const settingOf = (key) => (key === 'mates' ? MATES_KEY : layerKey(key))
const wordOf = (key, value) => (key === 'mates' ? (value ? 'on' : 'off') : value)
for (const [key, value] of fleetChanges) {
// eslint-disable-next-line no-await-in-loop
await visibilityDb.setSetting(settingOf(key), wordOf(key, value), userId)
}
for (const [id, key, value] of serverChanges) {
// eslint-disable-next-line no-await-in-loop
await db.setOverride(id, settingOf(key), value === null ? null : wordOf(key, value), userId)
}
const changed = {}
if (fleetChanges.length) changed.fleet = Object.fromEntries(fleetChanges)
if (serverChanges.length) {
changed.servers = {}
for (const [id, key, value] of serverChanges) {
changed.servers[id] = { ...(changed.servers[id] || {}), [key]: value === null ? 'inherit' : value }
}
}
return { ok: true, changed }
}
module.exports = {
LAYERS,
DEFAULTS,
DEFAULT_MATES,
MATES_KEY,
SETTINGS,
DERIVATION_VERSION,
POLL_MS,
LIVE_CACHE_MS,
RENDER_MEASURED,
layerKey,
renderStallSeconds,
narrower,
fleet,
forServer,
access,
mateIdsFor,
project,
geometryOf,
derive,
monumentsOf,
describeSwitches,
update,
}

View File

@@ -78,6 +78,18 @@ async function listForPolling() {
return rows.map(withToken)
}
/**
* One ENABLED server with its token, for a call to its sidecar made on a page's
* behalf (the map's live layers), or `null`. The same rule as `getPublic`: a
* disabled server is not there.
*/
async function getForCalling(id) {
if (!id) return null
const row = await db.getServer(id)
if (!row || !row.enabled) return null
return withToken(row)
}
/**
* The public view: every enabled server and what it last said.
*
@@ -186,6 +198,7 @@ module.exports = {
STALE_AFTER_MS,
withToken,
listForPolling,
getForCalling,
lastEnabledCount,
listPublic,
getPublic,

View File

@@ -93,19 +93,30 @@ const meets = (viewer, required) => viewerRank(viewer) >= requiredRank(required)
* question about somebody's standing must grant nothing.
*/
async function viewerLevel(req) {
return (await viewer(req)).level
}
/**
* The viewer's rung AND who they are, from the same re-read row — `{ level,
* userId }`, with `userId` null for anybody who resolves to `public`. The map's
* own-and-mates view (D115) needs the id, and it must come from the row that
* decided the rung: a banned account is nobody, whatever its token names.
*/
async function viewer(req) {
const nobody = { level: 'public', userId: null }
try {
const claimed = req.user || core.auth.getUserFromRequest(req)
if (!claimed || claimed.id == null) return 'public'
if (!claimed || claimed.id == null) return nobody
const user = await core.users.getById(claimed.id)
if (!user) return 'public'
if (user.status && user.status !== 'active') return 'public'
if (!user) return nobody
if (user.status && user.status !== 'active') return nobody
if (user.role === 'admin' || user.role === 'moderator') return 'staff'
return 'signed_in'
const level = user.role === 'admin' || user.role === 'moderator' ? 'staff' : 'signed_in'
return { level, userId: user.id != null ? user.id : claimed.id }
} catch (err) {
log.warn('could not resolve the viewer; treating them as anonymous', { error: err.message })
return 'public'
return nobody
}
}
@@ -278,6 +289,7 @@ module.exports = {
meets,
normalise,
viewerLevel,
viewer,
fleetPresence,
presenceFor,
canSeePresence,