Files
Module-Rust/server/index.js
wtclaude dd9b8b2711
Some checks failed
PR Checks / client-build (pull_request) Successful in 51s
PR Checks / frozen-manifest (pull_request) Successful in 53s
PR Checks / server-tests (pull_request) Failing after -17s
feat(safety): report, block and hide chat lines (MODULE_API 1.21.0)
USER_SAFETY.md step 3, the module half of report, block and hide.

- server/safety.js registers the rust.chat report target (snapshot for one
  reader, null when they could not have seen the line; staff's hide and
  unhide) and the steam game identity (ownersOf from rust_account_links).
- The events feed filters chat per reader, in the query: lines from a
  blocked account's Steam ids, the reader's unlinked Steam-ID blocks and the
  lines they hid. Signed-in readers get canBlock on each chat line (false for
  their own and for staff-linked players). Lines staff hid for everyone are
  left out for everyone but staff, who get them with hidden: true.
- rust_events gains hidden_at and hidden_by (ALTER ... IF NOT EXISTS).
- POST /admin/rust/chat/:id/hide and /unhide (admin, moderator), logged.
- The feed's chat lines get a menu: Report, Hide for me, Block player,
  and for staff Hide for everyone / Show to everyone again.
- coreApi ^1.21.0; ci/core-ref.json pinned to the website#233 merge;
  routes.manifest.json and swagger-fragment.json regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-30 09:04:08 -05:00

272 lines
16 KiB
JavaScript

// ── The server entry point ─────────────────────────────────────────────────
//
// Core requires this file once, synchronously, while its own `app.js` is still
// being required, and calls the exported function with `(ctx, api)`. That is the
// entire server-side handshake: everything this module can reach arrives on
// `ctx`, and everything it can offer is registered through `api`.
//
// Normative: MODULE_API.md §2.2 (the entry point) and §2.4 (what you register).
//
// ── Three rules, and each one has a failure behind it ──────────────────────
//
// 1. **No `await`, and no database.** Core requires `app.js` in two build tools
// with the connection pool pointed at a dead port — the route-manifest
// generator and the OpenAPI generator both do it — so a module that queried
// at registration time would hang both. Anything that needs a live database
// goes in `onBoot`, which runs after the schema is up.
//
// 2. **Never resolve what core owns.** This module lives at
// `<website>/modules/rust/`, outside core's `server/`, so Node's resolver
// never reaches core's `node_modules` and `require('express')` from here
// simply fails. express, express-validator, the database, the logger and the
// middleware all arrive on `ctx` (§2.3) and are re-exported by `./core`. A
// second express in the process would be a second `Router` prototype, exactly
// as a second React would be a second renderer.
//
// 3. **Never reach into core's tree.** No relative path may escape this module's
// root. `scripts/checkImports.js` enforces it (§5.1) and CI runs it.
//
// ── Why the requires are INSIDE the function ───────────────────────────────
//
// Every file below reaches core through `./core`, whose members resolve `ctx`
// when they are CALLED. But a router writes `const express = core.express` at its
// own file scope, and that runs the moment the file is required. So
// `core.init(ctx)` has to happen before the first `require` of anything under
// `router/`. Hoisting these to the top of the file breaks the module with an
// error about a missing `ctx`, thrown from a file that never mentions one.
//
// Node caches modules, so requiring here costs nothing after the first call.
const core = require('./core')
/**
* @param {object} ctx what core hands the module (MODULE_API.md §2.3), frozen
* @param {object} api what the module registers (§2.4)
*/
module.exports = function register(ctx, api) {
core.init(ctx)
/* eslint-disable global-require */
const publicRust = require('./router/public/rust.router')
const playerRust = require('./router/player/rust.router')
const adminRust = require('./router/admin/rust.router')
const usersRust = require('./router/admin/usersRust.router')
const teamProvider = require('./model/clans/teamProvider')
const { TRIGGERS } = require('./engagement/triggers')
const { STREAMS } = require('./engagement/streams')
const { AUDIENCES } = require('./engagement/audiences')
const seeds = require('./engagement/seeds')
const eventLeases = require('./eventLeases')
const eventWorld = require('./eventWorld')
const eventRewards = require('./eventRewards')
const eventWorldCall = require('./eventWorldCall')
const eventDangerousTreasures = require('./eventDangerousTreasures')
const eventGuardedCrate = require('./eventGuardedCrate')
const eventRaidableBases = require('./eventRaidableBases')
const eventPermRewards = require('./eventPermRewards')
const eventCommands = require('./eventCommands')
const eventTemplates = require('./eventTemplates')
const boot = require('./boot')
const commands = require('./commands')
/* eslint-enable global-require */
const log = core.logger()
// One prefix, on each of the three tiers (R14). The keys here must match
// `module.json`'s `mounts` exactly — the loader compares the two and rejects a
// mismatch in EITHER direction, so a route never declared and a prefix declared
// and never registered both fail loudly at boot rather than quietly at runtime.
//
// Each router sits INSIDE its tier router, so it structurally cannot reach
// above its prefix, and the tier's gate is already applied: `public` is behind
// nothing by design, `admin` behind `noindex, isLoggedIn, requireRole(...)` and
// `player` behind `noindex, requireAuth`. Per-route gates go on top; the tier
// gate is never re-implemented.
//
// **Prefixes share ONE namespace with core's own, and the collision probe
// cannot see all of it.** Core answers several public routes mounted at the
// tier root rather than under a prefix — `/status` and `/version` among them —
// and the loader's check cannot find those. `/rust` collides with nothing on
// any of the three tiers, checked against core's mount tables rather than
// assumed.
api.registerRoutes({
public: { '/rust': publicRust },
player: { '/rust': playerRust },
admin: { '/rust': adminRust },
})
// R13's first extension slot (§2.4). Core declares `admin.users.detail` on
// `/api/v1/admin/users/:id` and we fill it; the router receives the parent's
// `req.params.id` through `mergeParams`. Core's own routes on the resource are
// declared before the slot is mounted, so core wins any path conflict — it owns
// the user, and this module owns what it can say about one.
//
// **It is declared twice, in two different places, on purpose.** This call is
// the SERVER half and `module.json`'s `extensions` array is held against it by
// the loader. The CLIENT half is `registry.registerExtension(ID,
// 'admin.users.detail', …)` in `entry.jsx` and must NOT appear in that array —
// phase 1 found that the hard way with `site.footer.status`, which is a client
// slot and fails the load outright when named there.
api.registerExtension('admin.users.detail', usersRust)
// Teams (R5, PLAN.md §24). A first-party Rust clan is a Team, and this module
// becomes the deployment's one authoritative source of them. Core asks; the
// provider answers from the clan boards (`model/clans`), and refuses rather
// than guessing whenever no board is current.
//
// **One provider per deployment**, so a site running module-uo as well cannot
// have both — the second registration is a collision core reports against the
// module that made it. That is core's rule and a real constraint on a mixed
// UO + Rust site; it is recorded in §24 rather than worked around here.
api.registerTeamProvider(teamProvider)
// Notifications and engagement (R7, PLAN.md §25). Four registrations that are
// one decision, because they only mean something together:
//
// triggers what can happen, what a template may say about it, and the
// widest audience a rule on it may EVER have — the security
// boundary; core refuses a rule that widens a ceiling
// streams which of those may reach a phone. Core pushes an engagement
// rule only to devices subscribed to a stream of the SAME id, so
// a trigger missing here can never buzz anybody (D65)
// audiences named sets of people over this module's data, for an operator
// to point a rule at; each answers user ids and nothing else
// seeds the two bodies worth writing, and one disabled rule group per
// family — installing this module mails nobody
//
// What fires them is `engagement/emit.js`, off the ingest cursor and the
// refresh. Registration is a claim, not a call: nothing here touches the
// database, and the seeds are written by core after the schema is up.
//
// The announce leg arrived with phase 13b (D62, D104), when there was a chat
// verb to deliver through; it is registered below with the rewards. There is
// still no post hook: nothing in game mirrors a post as state.
api.registerEventTriggers(TRIGGERS)
api.registerNotificationStreams(STREAMS)
api.registerAudiences(AUDIENCES)
api.registerEngagementSeeds({ templates: seeds.TEMPLATES, ruleGroups: seeds.RULE_GROUPS })
// The lifecycle hooks (§2.5). `onBoot` runs after core's schema, after this
// module's schema fragment, and BEFORE the HTTP listener binds — so a module
// that must not serve traffic until it has warmed a cache gets that for free.
// It has no timeout, deliberately: a slow boot delays the listener, which is the
// guarantee rather than a problem to be timed out.
//
// `onShutdown` runs while core's database pool and push dispatcher are still
// open, because flushing through them is the only thing it is for. It gets a
// five-second budget and is abandoned past it.
api.onBoot(boot.onBoot)
api.onShutdown(boot.onShutdown)
// Sign in with Steam (MODULE_API 1.17.0; PLAN_REDESIGNS §15). A Steam account
// linked on the site is a linked Rust player (D392): core asks before it writes
// the sign-in identity, and this module refuses a Steam id that is another
// account's Rust link (D396); once it is written, the Rust link is made. Both
// are in the links model beside `/link`'s own write, and neither runs at
// registration — core calls them from a signed-in user's link flow.
const links = require('./model/links/links.model') // eslint-disable-line global-require
api.onIdentityLinking(links.checkSteamSignin)
api.onIdentityLinked(links.linkFromSteamSignin)
// Account deletion (MODULE_API 1.20.0; ACCOUNT_DELETION.md §4.4, AD10). Core
// asks before the users row goes: every Steam id the account holds is unlinked
// through the player's own unlink, and the permission sync is marked dirty so
// the withdrawn grants reach the game on the next tick. It cannot stop the
// deletion, and never waits on a game.
api.onUserDeleting(links.unlinkForDeletion)
// Report, block and hide (MODULE_API 1.21.0; USER_SAFETY.md §4.2). A chat line
// is a `rust.chat` report target: core files the report and keeps the queue,
// and asks this module what a line is, for one reader, and to hide it for
// everyone. A speaker is known by their Steam id, and `steam` says which
// website account one is linked to, so a block lands on the account first
// (S12). The feed's own filtering and `canBlock` are in the events route.
const safety = require('./safety') // eslint-disable-line global-require
api.registerReportTargets(safety.REPORT_TARGETS)
api.registerGameIdentities(safety.GAME_IDENTITIES)
// The public demo's seed (MODULE_API 1.18.0, DM15): the rig's server row and
// demo-player's Steam link, written by this module from its part of the
// fixture. Called only by `npm run seed:demo` on a DEMO_MODE deployment. The
// demo's other locks are `demoLocks` in module.json and `demo.js`.
const demoSeed = require('./demoSeed') // eslint-disable-line global-require
api.onDemoSeed(demoSeed.onDemoSeed)
// The leases (PLAN.md §27, protocol 8): what an event may BORROW on a server
// and must give back. Core's `core.lease` is the verb; these are the values it
// may name and the four callables each ships. Every lease is targeted and the
// target names the server (D73), which is how one value on one server gets
// exactly one holder without core learning what a server is.
//
// The option sources are the three targets' own (D78).
api.registerEventLeases(eventLeases.LEASES)
// The world verbs (PLAN.md §28, protocol 9): what an event MAKES and gives
// back — a zone, crates, NPCs — and the budgets that price them, each declared
// beside the verb that spends it (D79, D89). A lease spends none of them.
//
// The rewards (PLAN.md §29, protocol 10) join them: the tally, the kit reward
// and the chat line, with the two budgets they spend. Registered in the same
// calls' neighbours, not merged into eventWorld's arrays, so each file keeps
// its own statement of what it declares.
//
// Permissions as rewards (PLAN_REDESIGNS.md §12) beside them: any registered
// permission, lent until the run ends or given for good, each on its own budget.
//
// Calling one of Rust's own world events (PLAN_REDESIGNS.md §14.3.1, protocol
// 14) beside them: kept when the run ends, on a budget of its own. Starting a
// Dangerous Treasures event (§14.3.4) and placing a guarded crate (§14.3.5)
// likewise, each on its own budget; and console commands an author typed
// (§16, D382-D385), priced in commands; and spawning a RaidableBases base
// (§11.4), ledgered like a zone when the raids helper makes it the run's.
api.registerEventBudgets([...eventWorld.BUDGETS, ...eventRewards.BUDGETS, ...eventPermRewards.BUDGETS, ...eventWorldCall.BUDGETS, ...eventDangerousTreasures.BUDGETS, ...eventGuardedCrate.BUDGETS, ...eventCommands.BUDGETS, ...eventRaidableBases.BUDGETS])
api.registerEventActions([...eventWorld.ACTIONS, ...eventRewards.ACTIONS, ...eventPermRewards.ACTIONS, ...eventWorldCall.ACTIONS, ...eventDangerousTreasures.ACTIONS, ...eventGuardedCrate.ACTIONS, ...eventCommands.ACTIONS, ...eventRaidableBases.ACTIONS])
// The templates (PLAN_REDESIGNS.md §2.5, MODULE_API 1.13.0): a kit weekend
// as a starting point on core's New event screen. Core keeps nothing about a
// template; what it builds is an ordinary draft.
api.registerEventTemplates(eventTemplates.TEMPLATES)
// News in game chat (D104). Core enqueues every registered leg for every
// published post, so the leg itself sends only to the servers whose switch an
// operator turned on — off by default, and a server that is down is skipped.
api.registerAnnounceLeg(eventRewards.LEG)
// ONE call for every option source: core takes a batch once, as this module's
// complete statement, and refuses a second.
api.registerEventOptionSources([
...eventLeases.OPTION_SOURCES,
...eventWorld.OPTION_SOURCES,
...eventRewards.OPTION_SOURCES,
...eventPermRewards.OPTION_SOURCES,
...eventWorldCall.OPTION_SOURCES,
...eventDangerousTreasures.OPTION_SOURCES,
...eventGuardedCrate.OPTION_SOURCES,
...eventRaidableBases.OPTION_SOURCES,
])
// The slash commands (phase 16, R11). The handlers run HERE, in the website
// process; the bot pulls the definitions and dispatches each call back. Every
// command is `access: 'everyone'` and resolves its own audience gate inside the
// handler, and every answer narrower than public is sent to the caller alone
// (D127) — see `commands/common.js`.
api.registerSlashCommands(commands)
log.info('registered', {
version: require('../module.json').version,
routes: 'public:/rust player:/rust admin:/rust',
extensions: 'admin.users.detail',
teams: 'first-party clans',
triggers: TRIGGERS.length,
streams: STREAMS.length,
audiences: AUDIENCES.length,
leases: eventLeases.LEASES.length,
actions: eventWorld.ACTIONS.length + eventRewards.ACTIONS.length + eventPermRewards.ACTIONS.length + eventWorldCall.ACTIONS.length + eventDangerousTreasures.ACTIONS.length + eventGuardedCrate.ACTIONS.length + eventCommands.ACTIONS.length + eventRaidableBases.ACTIONS.length,
budgets: eventWorld.BUDGETS.length + eventRewards.BUDGETS.length + eventPermRewards.BUDGETS.length + eventWorldCall.BUDGETS.length + eventDangerousTreasures.BUDGETS.length + eventGuardedCrate.BUDGETS.length + eventCommands.BUDGETS.length + eventRaidableBases.BUDGETS.length,
announceLeg: eventRewards.LEG.leg,
commands: commands.map((cmd) => cmd.name).join(' '),
reportTargets: safety.REPORT_TARGETS.map((t) => t.id).join(' '),
optionSources: eventLeases.OPTION_SOURCES.length + eventWorld.OPTION_SOURCES.length + eventRewards.OPTION_SOURCES.length + eventPermRewards.OPTION_SOURCES.length + eventWorldCall.OPTION_SOURCES.length + eventDangerousTreasures.OPTION_SOURCES.length + eventGuardedCrate.OPTION_SOURCES.length + eventRaidableBases.OPTION_SOURCES.length,
})
}