Files
Module-Rust/server/index.js
wtclaude c94271104f
All checks were successful
PR Checks / server-tests (pull_request) Successful in 24s
PR Checks / frozen-manifest (pull_request) Successful in 46s
PR Checks / client-build (pull_request) Successful in 8m3s
feat(rust): Teams from first-party clans (phase 9, protocol 6)
A first-party Rust clan is a Team (R5). This module becomes the site's
Team provider and answers core from the plugin's `clans` board. Design
of record: docs/modules/rust/PLAN.md §24, D47-D58.

- The store: rust_clans, rust_clan_members and rust_clan_boards. A clan's
  identity is <serverId>:<clanId>:<createdMs> (D52), because the game
  restarts clan ids whenever its clan database version changes.
- The provider (D53): getTeams is complete only when every server's
  board is fresh, supported and untruncated. It is partial when some
  are, and refuses when none are. Freshness is judged by the website's
  clock, from when the board's `t` last advanced.
- Only a complete board may mark a clan gone. A board at the game's
  100-clan ceiling (D55), or one with an unreadable row, proves nothing
  about what it leaves out.
- Leadership is diffed board to board and published (D54). The five clan
  events are published as team.* kinds, and written to the Team feed as
  members-only lines (D49).
- Core only writes feed items for a Team it already holds. So the last 10
  minutes of clan events are re-offered on each board refresh, deduped by
  a sha1 key: core clamps a dedupeKey to 40 characters, and a readable key
  would be truncated into collisions.
- projectRoster and the clan page share one audience rule (D48): the
  clan's linked members and staff by default, re-read from the users row.
  The setting lives on Admin > Rust visibility, which also warns about
  uMod Clans (D47) and the ceiling.
- Public: GET servers/:id/clans (the list is public, D58) and
  GET clans/:externalId. The client adds a Clans tab and
  /rust/clans/:externalId, with three module slots for core's notify,
  activity and forum contributions (D56).
- Linking and unlinking an account ask core to reconcile Teams (D57).
- The clan kinds are staff-class in the public feed allowlist.
- PROTOCOL_VERSION is now 6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-23 05:14:18 -05:00

135 lines
7.2 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 boot = require('./boot')
/* 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)
// 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)
// Everything else this module will register — the event triggers and
// audiences, the engagement seeds, the four event catalogues, the
// notification streams and the slash commands — is deliberately absent. Each
// arrives with the phase that has something real to put in it. A registration
// with nothing behind it is worse than a missing one: a declared trigger
// nothing emits and a declared slot nothing fills are both surfaces an operator
// can configure and then wait on.
log.info('registered', {
version: require('../module.json').version,
routes: 'public:/rust player:/rust admin:/rust',
extensions: 'admin.users.detail',
teams: 'first-party clans',
})
}