Five read-only commands registered with api.registerSlashCommands: /status, /wipe, /top, /online and /clan (D126). Every refusal is private, and any answer narrower than public (online names, a clan roster) goes to the caller alone (D127). No command asks a sidecar. The next wipe (D128, D130): six nullable columns on rust_servers, a pure nextWipe(row, now) with the zone arithmetic through Intl, computed on every read. The public server shape gains nextWipe; the admin shape gains the stored schedule; PUT /admin/rust/servers/:id takes the six fields and writes them only when wipeRule is present. server/commands joins ci/bundle.json, which checkBundle caught. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
182 lines
9.4 KiB
JavaScript
182 lines
9.4 KiB
JavaScript
// ── Everything this module reaches in core ─────────────────────────────────
|
|
//
|
|
// `ctx` arrives once, as an argument to `register()` (MODULE_API.md §2.3). The
|
|
// code beneath it — models, controllers, utilities — is ordinary Node that
|
|
// requires its dependencies at file scope, the way any Node file does. This file
|
|
// is what lets both of those be true at the same time.
|
|
//
|
|
// **Every export is a lazy accessor, not a stored reference, and that is the
|
|
// whole point.** A model writes
|
|
//
|
|
// const { query } = require('../../core')
|
|
//
|
|
// at require time, which is before `register()` has been called and therefore
|
|
// before any `ctx` exists. Handing out `ctx.db.query` at that moment would hand
|
|
// out `undefined`, permanently, and the failure would surface much later as a
|
|
// TypeError inside a model with no clue pointing here. So each member resolves
|
|
// `ctx` when it is CALLED. Require order stops mattering for everything except
|
|
// `core.init()` itself, which `index.js` runs first.
|
|
//
|
|
// The same rule in the other direction: **never destructure off `ctx` at init
|
|
// time.** Core is free to hand over a getter — `ctx.site.baseUrl` is one — and a
|
|
// value captured once is a value that cannot change.
|
|
//
|
|
// If `ctx` is missing every accessor throws the same message. The only ways to
|
|
// reach one before `register()` are a require cycle or a test that forgot to call
|
|
// `init`, and both want naming rather than `undefined`.
|
|
//
|
|
// ── This file is a NARROWING, on purpose ───────────────────────────────────
|
|
//
|
|
// §2.3 lists everything core hands over. What is re-exported below is only what
|
|
// this module actually uses, which is the discipline worth copying: the file is
|
|
// then an honest statement of what your module depends on, and a test double for
|
|
// it (see `test/_fakes.js`) is a complete one. Add a member here when you reach
|
|
// for it — not in advance.
|
|
|
|
let ctx = null
|
|
|
|
function need() {
|
|
if (!ctx) {
|
|
throw new Error('rust: core accessed before register() — see server/core.js')
|
|
}
|
|
return ctx
|
|
}
|
|
|
|
/** Called once, first thing in `register()`. */
|
|
function init(value) {
|
|
ctx = value
|
|
}
|
|
|
|
/** Test seam. Nothing in the module calls this; there is no de-registration. */
|
|
function _reset() {
|
|
ctx = null
|
|
}
|
|
|
|
// A logger that can be taken at require time and used after `register()`.
|
|
//
|
|
// A file writes `const log = require('../core').logger('servers')` at file scope,
|
|
// so the object returned has to exist before `ctx` does. It is a façade whose
|
|
// four methods each resolve the real logger when called. Core namespaces the
|
|
// output with your module id, so these come out as `[rust:servers]`.
|
|
function logger(namespace) {
|
|
const call = (level) => (message, meta) => need().log(namespace)[level](message, meta)
|
|
return { error: call('error'), warn: call('warn'), info: call('info'), debug: call('debug') }
|
|
}
|
|
|
|
module.exports = {
|
|
init,
|
|
_reset,
|
|
logger,
|
|
|
|
// Shared server dependencies. Core owns exactly one express, as it owns
|
|
// exactly one React on the client, and for the same reason: a second copy in
|
|
// the process is a second Router prototype and a second set of `instanceof`
|
|
// checks. A module could not resolve these for itself even if it were allowed
|
|
// to — it lives outside core's `server/` (§7.2).
|
|
get express() { return need().express },
|
|
get validator() { return need().validator },
|
|
|
|
// The database. `query(sql, params)` is what every `*.db.js` file uses; raw
|
|
// parameterised SQL, no ORM, the same as core. `pool` is there for the rare
|
|
// case that needs a connection it can hold (a streamed import, say).
|
|
query: (...args) => need().db.query(...args),
|
|
get pool() { return need().db.pool },
|
|
|
|
// Read-only access to who is asking. Minting a session is core's job; a module
|
|
// that needs an identity needs to *read* one.
|
|
auth: { getUserFromRequest: (...args) => need().auth.getUserFromRequest(...args) },
|
|
|
|
// One user by id (MODULE_API.md §2.3, 1.1.0). Here for the presence gate
|
|
// (`model/visibility`): `getUserFromRequest` decodes a token and nothing more,
|
|
// so the role in it is the role the account had when the token was minted. A
|
|
// moderator demoted this morning would keep reading who is online until their
|
|
// token expired. Re-reading the row is what makes a demotion — or a ban — take
|
|
// effect on the next request, the same promise core's admin tier makes.
|
|
users: { getById: (...args) => need().users.getById(...args) },
|
|
|
|
// Core's middleware, taken as values rather than wrapped: express stores the
|
|
// function reference at mount time, so a wrapper is what would end up in the
|
|
// stack. Routers are built inside `register()`, so `ctx` is set by then.
|
|
get middleware() { return need().middleware },
|
|
|
|
// Firing a declared event (MODULE_API.md §2.3). Wrapped as a call rather than
|
|
// exposed as `get events()`, so that `require('../core').emit` taken at file
|
|
// scope still resolves `ctx` at call time like everything else here.
|
|
//
|
|
// **It returns nothing, and in production it never throws at the caller.** The
|
|
// emit is the end of this module's involvement: core validates the payload
|
|
// against the declared contract, decides which rules match, resolves who they
|
|
// reach and sends. A module cannot address a person, choose a channel or write
|
|
// a subject line, and this seam is deliberately too narrow to try (§2.7).
|
|
//
|
|
// Outside production a bad payload throws here rather than being logged, which
|
|
// is the point: you meet the mismatch in your own tests instead of in an
|
|
// operator's log six weeks later.
|
|
emit: (triggerId, envelope) => need().events.emit(triggerId, envelope),
|
|
|
|
// Secrets at rest (MODULE_API.md §2.3). Core's AES-256-GCM box, keyed by the
|
|
// deployment's `SECRET_ENC_KEY` — the same one that protects core's own OAuth
|
|
// client secrets and the uo-link token.
|
|
//
|
|
// **The sidecar token goes through this and nothing else.** It is the
|
|
// credential that reaches a game host, and it is stored encrypted and returned
|
|
// to no client ever: the admin API accepts a new value and reports only
|
|
// whether one is set. Returned as the box rather than as two wrapped functions
|
|
// so that `encrypt`/`decrypt` stay a matched pair at the call site.
|
|
secretBox: () => need().secretBox,
|
|
|
|
// The admin activity log (MODULE_API.md §2.3, 1.1.0). Every write on this
|
|
// module's admin tier goes through it, because the rows it writes are the
|
|
// credentials that reach a game host — "who changed the sidecar URL" is a
|
|
// question an operator will eventually need answered, and there is no second
|
|
// place it is recorded.
|
|
activity: { log: (...args) => need().activity.log(...args) },
|
|
|
|
// Telling core the game restarted (MODULE_API.md §2.3, 1.10.0). The one thing
|
|
// the event contract adds to `ctx`, and it is here for a reason worth carrying:
|
|
// **core has no concept of the game being up.** It sees `{ ok: false, retry: true }`
|
|
// and cannot tell a wedged sidecar from a shard that rebooted and lost every
|
|
// creature an event spawned. Only this module knows, because only this module
|
|
// watches the feed the boot id arrives on.
|
|
//
|
|
// Calling it asks core to sweep its resource ledger and put the question back
|
|
// to this module's actions, as `reconcile({ runId, resources })`. Fire and
|
|
// forget: it returns at once and the sweep happens on core's own time.
|
|
//
|
|
// See `boot.js` for the watch that calls it, and `config/eventActions.js` for
|
|
// the answer. Named longer than the `ctx` member it wraps because this object
|
|
// is flat — `core.emit` is already a little ambiguous and `core.reconcile()`
|
|
// would be worse, since a module has more than one thing it could reconcile.
|
|
reconcileEvents: () => need().events.reconcile(),
|
|
|
|
// Teams (MODULE_API.md §2.3, 1.6.0) — the push half of the provider this
|
|
// module registers (`model/clans/teamProvider.js`). Three calls, all
|
|
// fire-and-forget, and core's contract is that none of them can make this
|
|
// module's call site slow or turn a background failure into its error:
|
|
//
|
|
// publish(event) a membership or leadership change, as it happened
|
|
// reconcile({reason}) "the set may have changed, come and ask" — debounced
|
|
// pushActivity(items) the per-Team feed, idempotent on each item's dedupeKey
|
|
//
|
|
// Correctness comes from reconciliation either way; `publish` only makes a
|
|
// change visible sooner. Wrapped as calls, like `emit`, so a file that takes
|
|
// `core.teams` at require time still resolves `ctx` when it is used.
|
|
teams: {
|
|
publish: (event) => need().teams.publish(event),
|
|
reconcile: (options) => need().teams.reconcile(options),
|
|
pushActivity: (items) => need().teams.activity.push(items),
|
|
},
|
|
|
|
// Deployment facts. `moduleRoot` is the absolute path to `modules/<id>/` — the
|
|
// only correct way to find a file you shipped, because the working directory is
|
|
// core's and the module's location is the loader's business.
|
|
get moduleRoot() { return need().paths.moduleRoot },
|
|
get moduleId() { return need().moduleId },
|
|
|
|
// The site's public address, with no trailing slash (§2.3, 1.1.0). Phase 16's
|
|
// slash commands link an answer's title to its page; a chat message has no
|
|
// origin to be relative to. A getter over core's getter, so an operator who
|
|
// changes `APP_BASE_URL` is read at the call, never captured at load.
|
|
get baseUrl() { return need().site.baseUrl },
|
|
}
|