Compare commits
18 Commits
883438009d
...
edge
| Author | SHA1 | Date | |
|---|---|---|---|
| b1abd87c3d | |||
| f35e70e7d3 | |||
| 43147b796a | |||
| a1b6d155a1 | |||
| 0876a1d568 | |||
| f3e274b33d | |||
| 0a1e558942 | |||
| baffaa46c9 | |||
| 28e46771b1 | |||
| 8df850f73e | |||
| 7e1f037aad | |||
| 22fd8c5da7 | |||
| 5ce711048c | |||
| f211969ee1 | |||
| 9018e55488 | |||
| b33d21d71b | |||
| 986f460b6e | |||
| 862c328176 |
229
.gitea/workflows/pr-checks.yml
Normal file
229
.gitea/workflows/pr-checks.yml
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
# Gate every pull request into `main` on a fast, DB-free check suite, so a broken
|
||||||
|
# build or a failing test can't reach the branch that gets released.
|
||||||
|
#
|
||||||
|
# Mirrors RunicGateway/website's pr-checks.yml — this module is two npm packages
|
||||||
|
# shaped like that repo's `server/` and `client/`, and it is loaded into that
|
||||||
|
# repo's process, so it is checked the same way with the same Node version.
|
||||||
|
#
|
||||||
|
# Phase 1 built all of these checks and ran them BY HAND. That is the gap this
|
||||||
|
# file closes: a guard nothing invokes is a guard whose state nobody knows.
|
||||||
|
#
|
||||||
|
# ── What each job is really asking ───────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# The tests are the ordinary half. The `check:*` scripts are the interesting one,
|
||||||
|
# because they are the acceptance criteria of the module contract itself
|
||||||
|
# (docs/website/MODULE_API.md Part 5) rather than of this module's behaviour:
|
||||||
|
#
|
||||||
|
# • `server: check:imports` — no relative path escapes the module root, and no
|
||||||
|
# shipped file resolves a bare specifier. A module that reaches into core's
|
||||||
|
# tree works right up until core moves a file, and the whole boundary is
|
||||||
|
# worth exactly as much as this check is (§5.1).
|
||||||
|
#
|
||||||
|
# • `server: check:bundle` — the release ships everything the entry point can
|
||||||
|
# reach, and still declares no runtime dependency. Every other job here runs
|
||||||
|
# against the whole repo, but a release is a SUBSET of it (release.yml
|
||||||
|
# assembles from the include list in `ci/bundle.json`), and nothing else
|
||||||
|
# compares the two. Module-uo's v1.0.0 is the cautionary tale: `server/commands/`
|
||||||
|
# arrived in a cutover, the include list did not learn about it, and the
|
||||||
|
# module installed and then died at the register stage on the operator's box
|
||||||
|
# with "Cannot find module './commands/guild.command'". Green in CI, broken
|
||||||
|
# there — because the subset only exists in the release.
|
||||||
|
#
|
||||||
|
# • `client: check:externals` — the BUILT chunk has no bare imports left. That
|
||||||
|
# failure is invisible in source: `import { useState } from 'react'` is
|
||||||
|
# correct in every file, and whether it becomes core's React or a bare
|
||||||
|
# specifier no browser can resolve is decided by vite.config.js. It has to be
|
||||||
|
# asked of the artifact, so it runs after the build. (The other half — a
|
||||||
|
# shared dependency being BUNDLED — fails the build itself, from a
|
||||||
|
# resolution-time guard inside vite.config.js.)
|
||||||
|
#
|
||||||
|
# • `server: check:swagger` — `swagger-fragment.json` describes the routes this
|
||||||
|
# module registers, today. Core has no way to generate it: core is a prebuilt
|
||||||
|
# image, this module arrives on a volume afterwards, and it mounts through a
|
||||||
|
# call no static parser can follow. So the fragment core merges into
|
||||||
|
# `/api/docs.json` is whatever this repo committed, and a stale one documents
|
||||||
|
# a URL surface that does not exist (§2.8).
|
||||||
|
#
|
||||||
|
# • `frozen-manifest` — the job with the interesting shape. It clones CORE at
|
||||||
|
# the ref pinned in `ci/core-ref.json`, generates its route manifest twice
|
||||||
|
# (without this module, then with) and takes the difference. That difference
|
||||||
|
# is what this module serves, and it is checked three ways: it must match the
|
||||||
|
# committed `routes.manifest.json`, it must not have REMOVED or changed one of
|
||||||
|
# core's own routes, and every route in it must have an operation in
|
||||||
|
# `swagger-fragment.json` — the per-module form of core's rule that a route
|
||||||
|
# which isn't in the spec doesn't ship (§5.3, §2.8).
|
||||||
|
#
|
||||||
|
# Nothing else can ask those questions. Every other check here runs against
|
||||||
|
# this repo alone, where a mount prefix is a string in `server/index.js` and a
|
||||||
|
# documented path is a string in a JSON file; whether they name the same URL
|
||||||
|
# is a fact about a running core, and this is the only job that has one. It is
|
||||||
|
# also the only thing that can see the blind spot phase 1 had to check by
|
||||||
|
# reading: core answers several public routes mounted at the TIER ROOT rather
|
||||||
|
# than under a prefix (`/status`, `/version`), which the loader's own collision
|
||||||
|
# probe cannot find, so `/rust` being free is now asserted by a core.
|
||||||
|
#
|
||||||
|
# Enforcement (one-time, in the Gitea UI):
|
||||||
|
# Repository Settings → Branches → Branch Protection (rule for `main`)
|
||||||
|
# • Enable Status Check
|
||||||
|
# • Status check patterns: PR Checks / *
|
||||||
|
# Note: Gitea only lists a context in its dropdown after it has reported once,
|
||||||
|
# so let this workflow run on one PR first. The `PR Checks / *` glob matches
|
||||||
|
# without needing the dropdown, and keeps matching as jobs are added.
|
||||||
|
#
|
||||||
|
# Runner: the shared self-hosted `ubuntu-latest` runner. These jobs need only
|
||||||
|
# Node — no Docker socket, no database.
|
||||||
|
#
|
||||||
|
# Scope note: `edge` is gated as well as `main`, though this repo has no `edge`
|
||||||
|
# branch yet. Multi-phase work lands there first everywhere else in this project,
|
||||||
|
# and gating only the `main` hop would run these checks for the first time at the
|
||||||
|
# cutover — the one moment a red build is most expensive to discover. Naming the
|
||||||
|
# branch before it exists costs nothing; an Android workstream that landed nine
|
||||||
|
# PRs on an ungated `edge` is why it is here from the start.
|
||||||
|
|
||||||
|
name: PR Checks
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main, edge]
|
||||||
|
|
||||||
|
# A newer push to the same PR cancels the in-flight run.
|
||||||
|
concurrency:
|
||||||
|
group: pr-checks-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
# npm's own retry, turned up. The shared runner reads ETIMEDOUT from the registry
|
||||||
|
# often enough to matter, and a red X that means "the network hiccuped" costs a
|
||||||
|
# reviewer more than it costs the runner to retry, and teaches everyone to re-run
|
||||||
|
# rather than read a failure.
|
||||||
|
env:
|
||||||
|
NPM_CONFIG_FETCH_RETRIES: 5
|
||||||
|
NPM_CONFIG_FETCH_RETRY_MINTIMEOUT: 20000
|
||||||
|
NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT: 120000
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
server-tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: server/package-lock.json
|
||||||
|
|
||||||
|
# `npm ci` rather than `npm install`: it also proves the lockfile is in
|
||||||
|
# sync with package.json instead of silently updating it.
|
||||||
|
- name: Install server deps
|
||||||
|
run: npm ci --prefix server
|
||||||
|
|
||||||
|
- name: Run server tests
|
||||||
|
run: npm test --prefix server
|
||||||
|
|
||||||
|
- name: Check the module boundary (MODULE_API.md §5.1)
|
||||||
|
run: npm run check:imports --prefix server
|
||||||
|
|
||||||
|
- name: Check the release ships what the module requires
|
||||||
|
run: npm run check:bundle --prefix server
|
||||||
|
|
||||||
|
- name: Check the OpenAPI fragment is current (MODULE_API.md §2.8)
|
||||||
|
run: npm run check:swagger --prefix server
|
||||||
|
|
||||||
|
client-build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: client/package-lock.json
|
||||||
|
|
||||||
|
- name: Install client deps
|
||||||
|
run: npm ci --prefix client
|
||||||
|
|
||||||
|
# The build comes FIRST, and that ordering is load-bearing. Two of the
|
||||||
|
# client tests read `dist/entry.js` — the chunk's externals, and what it
|
||||||
|
# registers when imported against a fake `window.__rg` — and both skip when
|
||||||
|
# there is no build. Run the other way round they skip silently in CI, which
|
||||||
|
# is the worst of both: green, and not asking the question.
|
||||||
|
- name: Build the client chunk
|
||||||
|
run: npm run build --prefix client
|
||||||
|
|
||||||
|
- name: Run client tests
|
||||||
|
run: npm test --prefix client
|
||||||
|
|
||||||
|
- name: Check the built chunk's externals (MODULE_API.md §3.6)
|
||||||
|
run: npm run check:externals --prefix client
|
||||||
|
|
||||||
|
# ── The URLs this module actually serves ──────────────────────────────────
|
||||||
|
#
|
||||||
|
# Everything above proves the module against itself. This proves it against a
|
||||||
|
# real core: the one place where "the prefix I register" and "the path I
|
||||||
|
# document" are the same fact rather than two strings that ought to agree.
|
||||||
|
#
|
||||||
|
# The module is COPIED into the core checkout, never symlinked — core's loader
|
||||||
|
# filters its scan with `entry.isDirectory()`, which reports a link as a link
|
||||||
|
# and skips it silently, so a symlinked module produces a manifest with no
|
||||||
|
# module routes in it and a diff that looks like the module registering nothing.
|
||||||
|
frozen-manifest:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
path: module
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
|
||||||
|
# Anonymous HTTPS, and a full clone rather than a shallow one: the pin is a
|
||||||
|
# commit sha, and `--depth 1` can only fetch a branch tip.
|
||||||
|
- name: Clone core at the pinned ref (MODULE_API.md §5.3)
|
||||||
|
run: |
|
||||||
|
REPO=$(node -p "require('./module/ci/core-ref.json').repo")
|
||||||
|
REF=$(node -p "require('./module/ci/core-ref.json').ref")
|
||||||
|
echo "core: $REPO @ $REF"
|
||||||
|
git clone --quiet "$REPO" core
|
||||||
|
git -C core checkout --quiet "$REF"
|
||||||
|
|
||||||
|
- name: Install core's server deps
|
||||||
|
run: npm ci --prefix core/server
|
||||||
|
|
||||||
|
# Core alone. `--check` first, so a pin that no longer regenerates its own
|
||||||
|
# committed manifest fails HERE, naming the pin, instead of showing up below
|
||||||
|
# as this module having removed a route it never touched.
|
||||||
|
- name: Generate core's manifest without this module
|
||||||
|
run: |
|
||||||
|
npm run routes:manifest --prefix core/server -- --check
|
||||||
|
cp core/server/routes.manifest.json before.json
|
||||||
|
|
||||||
|
# The chunk has to exist before the loader will accept the module at all —
|
||||||
|
# `client.entry` is validated during the manifest step of the scan, and a
|
||||||
|
# missing one is a load failure, not a warning.
|
||||||
|
- name: Build the client chunk
|
||||||
|
run: |
|
||||||
|
npm ci --prefix module/client
|
||||||
|
npm run build --prefix module/client
|
||||||
|
|
||||||
|
# No `npm ci` on the installed copy, because the shipped half declares no
|
||||||
|
# runtime dependencies and the release packs no `node_modules` (org lead,
|
||||||
|
# phase 2). `check:bundle` in the job above is what keeps that true; if it
|
||||||
|
# ever stops being true, this step and release.yml both grow an install.
|
||||||
|
- name: Install the module into core
|
||||||
|
run: |
|
||||||
|
mkdir -p core/modules/rust
|
||||||
|
tar -C module --exclude=.git --exclude=node_modules -cf - . | tar -C core/modules/rust -xf -
|
||||||
|
|
||||||
|
- name: Generate core's manifest with this module
|
||||||
|
run: |
|
||||||
|
npm run routes:manifest --prefix core/server
|
||||||
|
cp core/server/routes.manifest.json after.json
|
||||||
|
|
||||||
|
- name: Check the frozen manifest and the fragment's coverage
|
||||||
|
working-directory: module
|
||||||
|
run: node server/scripts/frozenManifest.js --before ../before.json --after ../after.json --check
|
||||||
442
.gitea/workflows/release.yml
Normal file
442
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,442 @@
|
|||||||
|
# Build and publish the installable bundle: `module-rust-<version>.tar.gz` plus
|
||||||
|
# the manifest carrying its sha256 (docs/website/MODULE_SYSTEM.md §2.3, §2.5).
|
||||||
|
#
|
||||||
|
# ── What a release IS here ──────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# **An operator never builds anything** (MODULE_SYSTEM.md §1.14 — the constraint
|
||||||
|
# the whole module system is shaped around). So a release is not source: it is the
|
||||||
|
# directory core's loader expects to find at `modules/rust/`, already assembled —
|
||||||
|
# the prebuilt client chunk, the schema fragment and the OpenAPI fragment — packed
|
||||||
|
# as it will be unpacked. Core's admin install downloads the tarball, verifies it
|
||||||
|
# against the `sha256` in the manifest, and unpacks it onto the volume. Nothing
|
||||||
|
# runs `npm` on the way.
|
||||||
|
#
|
||||||
|
# **And nothing is installed into the bundle either**, which is where this repo
|
||||||
|
# differs from Module-uo: the shipped half declares no runtime dependencies, so
|
||||||
|
# there is no `npm ci --omit=dev` and no `server/node_modules` in the tarball (org
|
||||||
|
# lead, phase 2). That is a decision worth being loud about rather than a detail —
|
||||||
|
# `server/scripts/checkBundle.js` fails the PR that adds a dependency without also
|
||||||
|
# teaching this file to install and pack it, because a bundle that declares an
|
||||||
|
# import it does not carry fails the same way a missing directory does.
|
||||||
|
#
|
||||||
|
# ── The version is DERIVED, and the declaration is a floor ──────────────────
|
||||||
|
#
|
||||||
|
# The engine `link`, `installer` and `Module-uo` already run (MODULE_SYSTEM
|
||||||
|
# §2.7.1, decision 19 as amended):
|
||||||
|
#
|
||||||
|
# feat!: / BREAKING CHANGE -> major feat: -> minor fix|perf: -> patch
|
||||||
|
# nothing releasable -> no release is cut
|
||||||
|
# (first ever run, no tag) -> releases what module.json declares
|
||||||
|
#
|
||||||
|
# Module-uo learned this the expensive way: it released only when a merge left
|
||||||
|
# `module.json` at a version with no release yet — the version DECLARED, never
|
||||||
|
# computed — and between 2026-08-12 and 2026-08-19 that cost it *every* bundle,
|
||||||
|
# because nine phases of work landed without anyone touching that line.
|
||||||
|
#
|
||||||
|
# **The declared version is kept as a floor, not deleted.** If `module.json` names
|
||||||
|
# a version above the newest tag, that version releases. Raising it by hand is how
|
||||||
|
# you say "this one is a minor, whatever the subjects imply", and it is the natural
|
||||||
|
# place to move when a `coreApi` bump forces the question.
|
||||||
|
#
|
||||||
|
# The number that ships is therefore the TAG, and CI writes it into the
|
||||||
|
# `module.json` inside the bundle at assembly time. The committed `module.json` is
|
||||||
|
# a floor and a starting point, not a record of the last release — a release engine
|
||||||
|
# that has to commit a bump back to `main` stops working the day someone protects
|
||||||
|
# the branch, and this one is protected.
|
||||||
|
#
|
||||||
|
# ── The backdoor ────────────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# `workflow_dispatch` publishes on demand, for the case the rules above cannot
|
||||||
|
# reach: `module.json` changed in a way worth shipping — a widened `coreApi`, a
|
||||||
|
# new mount, a capability — with no releasable code behind it. Leave `version`
|
||||||
|
# blank to bump the newest tag by `bump` (default `patch`), or name an exact
|
||||||
|
# version to publish that. A dispatch releases even when nothing in the log is
|
||||||
|
# releasable; that is the entire point of pressing the button.
|
||||||
|
#
|
||||||
|
# Re-running on a version that is already released is a no-op, so a rerun after an
|
||||||
|
# unrelated failure is safe. A tag that exists with no release behind it is NOT a
|
||||||
|
# no-op — see the recovery branch in the plan step. That state is not theoretical:
|
||||||
|
# `servuo-plugins`' first release pushed its tag and then 401'd on the release API
|
||||||
|
# because the secret was absent, and without the recovery branch the repo would
|
||||||
|
# have been stuck there permanently.
|
||||||
|
#
|
||||||
|
# This workflow never writes to a branch. It tags and publishes, so `main` needs
|
||||||
|
# no push exception.
|
||||||
|
#
|
||||||
|
# Prerequisites (Settings → Actions → Secrets on RunicGateway/Module-Rust):
|
||||||
|
# REGISTRY_TOKEN — Gitea access token with `write:repository`, to push the tag
|
||||||
|
# and create the release.
|
||||||
|
|
||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: 'Exact version to publish (e.g. 0.1.1). Blank = bump the newest tag by the level below.'
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
bump:
|
||||||
|
description: 'Bump level when version is blank: patch | minor | major'
|
||||||
|
required: false
|
||||||
|
default: 'patch'
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: release-module-rust
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
env:
|
||||||
|
GITEA_HOST: gitea.whitlocktech.com
|
||||||
|
REPO: RunicGateway/Module-Rust
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
# Full history: the plan step reads every tag and every subject since the
|
||||||
|
# newest one, and a shallow clone has neither.
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
|
||||||
|
- name: Plan the release (version + changelog)
|
||||||
|
id: plan
|
||||||
|
env:
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
EVENT: ${{ github.event_name }}
|
||||||
|
IN_VERSION: ${{ github.event.inputs.version }}
|
||||||
|
IN_BUMP: ${{ github.event.inputs.bump }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p dist
|
||||||
|
git fetch --tags --force >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
DECLARED="$(node -p "require('./module.json').version")"
|
||||||
|
LAST_TAG="$(git describe --tags --match 'v*' --abbrev=0 2>/dev/null || true)"
|
||||||
|
CURRENT="${LAST_TAG#v}"
|
||||||
|
RANGE="${LAST_TAG:+${LAST_TAG}..}HEAD"
|
||||||
|
echo "module.json declares ${DECLARED}; newest tag is ${LAST_TAG:-<none>}"
|
||||||
|
|
||||||
|
SUBJECTS="$(git log --no-merges --format='%s' $RANGE || true)"
|
||||||
|
BODIES="$(git log --no-merges --format='%B' $RANGE || true)"
|
||||||
|
|
||||||
|
BUMP=none
|
||||||
|
if echo "$BODIES" | grep -qE 'BREAKING[ -]CHANGE' ; then BUMP=major; fi
|
||||||
|
if echo "$SUBJECTS" | grep -qE '^[a-z]+(\([^)]+\))?!:' ; then BUMP=major; fi
|
||||||
|
if [ "$BUMP" = none ] && echo "$SUBJECTS" | grep -qE '^feat(\([^)]+\))?:' ; then BUMP=minor; fi
|
||||||
|
if [ "$BUMP" = none ] && echo "$SUBJECTS" | grep -qE '^(fix|perf)(\([^)]+\))?:' ; then BUMP=patch; fi
|
||||||
|
|
||||||
|
bump() { # <x.y.z> <major|minor|patch> -> bumped
|
||||||
|
IFS=. read -r MA MI PA <<< "$1"
|
||||||
|
case "$2" in
|
||||||
|
major) echo "$((MA+1)).0.0" ;;
|
||||||
|
minor) echo "${MA}.$((MI+1)).0" ;;
|
||||||
|
patch) echo "${MA}.${MI}.$((PA+1))" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# `sort -V` orders version strings, so the higher of two is its last
|
||||||
|
# line. Used rather than a hand-rolled field compare because 0.10.0 vs
|
||||||
|
# 0.9.0 is exactly the comparison a string sort gets wrong.
|
||||||
|
higher() { printf '%s\n%s\n' "$1" "$2" | sort -V | tail -1; }
|
||||||
|
|
||||||
|
rank() { case "$1" in major) echo 3 ;; minor) echo 2 ;; patch) echo 1 ;; *) echo 0 ;; esac; }
|
||||||
|
bigger_bump() { if [ "$(rank "$1")" -ge "$(rank "$2")" ]; then echo "$1"; else echo "$2"; fi; }
|
||||||
|
|
||||||
|
VERSION=""
|
||||||
|
if [ -n "${IN_VERSION:-}" ]; then
|
||||||
|
# The backdoor's exact form. Deliberately unvalidated against the log:
|
||||||
|
# a human typed it, and the already-released check below is the only
|
||||||
|
# guard that matters.
|
||||||
|
VERSION="${IN_VERSION}"
|
||||||
|
echo "dispatch: publishing the requested version ${VERSION}"
|
||||||
|
else
|
||||||
|
LEVEL="$BUMP"
|
||||||
|
# A dispatch with nothing releasable in the log still releases — that
|
||||||
|
# is what the button is for. Where the log DOES say something, the
|
||||||
|
# larger of the two wins rather than the input: pressing the button on
|
||||||
|
# a log full of `feat:` without touching the dropdown would otherwise
|
||||||
|
# publish its `patch` default over a minor's worth of work, and a
|
||||||
|
# version that undersells its own contents cannot be taken back.
|
||||||
|
if [ "${EVENT:-}" = workflow_dispatch ]; then
|
||||||
|
LEVEL="$(bigger_bump "$LEVEL" "${IN_BUMP:-patch}")"
|
||||||
|
if [ "$BUMP" = none ]; then
|
||||||
|
echo "dispatch: nothing releasable in the log, bumping ${LEVEL} anyway"
|
||||||
|
elif [ "$LEVEL" != "$BUMP" ]; then
|
||||||
|
echo "dispatch: the log says ${BUMP}, the run asked for ${LEVEL} — taking ${LEVEL}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$CURRENT" ]; then
|
||||||
|
VERSION="$DECLARED" # first ever release: ship what is declared
|
||||||
|
elif [ "$LEVEL" != none ]; then
|
||||||
|
VERSION="$(bump "$CURRENT" "$LEVEL")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The floor. A `module.json` above the newest tag releases at that
|
||||||
|
# version even when the log says nothing and even when the log says
|
||||||
|
# patch.
|
||||||
|
if [ -n "$CURRENT" ] && [ "$DECLARED" != "$CURRENT" ] \
|
||||||
|
&& [ "$(higher "$DECLARED" "$CURRENT")" = "$DECLARED" ]; then
|
||||||
|
if [ -z "$VERSION" ] || [ "$(higher "$DECLARED" "$VERSION")" = "$DECLARED" ]; then
|
||||||
|
echo "module.json declares ${DECLARED}, above both ${CURRENT} and the derived version — releasing that."
|
||||||
|
VERSION="$DECLARED"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
RELEASE=true
|
||||||
|
if [ -z "$VERSION" ]; then
|
||||||
|
RELEASE=false
|
||||||
|
VERSION="$CURRENT"
|
||||||
|
echo "Nothing releasable since ${LAST_TAG} (no feat/fix/perf/breaking subject) — standing down."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# An existing tag is NOT automatically "nothing to do". A tag with no
|
||||||
|
# release behind it means a previous run tagged and then died before
|
||||||
|
# publishing — which is what happened on servuo-plugins' first release,
|
||||||
|
# where absent secrets took the release API call to 401 after the tag had
|
||||||
|
# already been pushed. Standing down on the tag alone makes that state
|
||||||
|
# permanent. Note this deliberately OVERRIDES the RELEASE=false above:
|
||||||
|
# with the tag in place there is nothing releasable after it, so the
|
||||||
|
# normal path would stand down, which is why it could never self-heal.
|
||||||
|
# Anything other than 200/404 — a network failure, a bad token — is not
|
||||||
|
# evidence of absence, and guessing "no" would publish over a good
|
||||||
|
# release, so refuse instead.
|
||||||
|
REUSE_TAG=false
|
||||||
|
if [ -n "$VERSION" ] && git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then
|
||||||
|
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')"
|
||||||
|
REL_HTTP="$(curl -s -o /dev/null -w '%{http_code}' \
|
||||||
|
-H "Authorization: token ${CI_TOKEN}" \
|
||||||
|
"https://${GITEA_HOST}/api/v1/repos/${REPO}/releases/tags/v${VERSION}" || echo 000)"
|
||||||
|
case "$REL_HTTP" in
|
||||||
|
200) echo "v${VERSION} is already released — nothing to do."; RELEASE=false ;;
|
||||||
|
404) echo "::warning::Tag v${VERSION} exists but has no release — a previous run failed after tagging. Reusing the tag and publishing the release it is missing."
|
||||||
|
REUSE_TAG=true; RELEASE=true ;;
|
||||||
|
*) echo "::error::Could not determine whether v${VERSION} is released (HTTP ${REL_HTTP}). Refusing to guess."; exit 1 ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Changelog range. A recovery run has nothing after the tag, so
|
||||||
|
# summarize what the tag itself contains rather than emitting an empty
|
||||||
|
# list: the range that produced it, i.e. previous-tag..this-tag.
|
||||||
|
if [ "$REUSE_TAG" = true ]; then
|
||||||
|
PREV_TAG="$(git describe --tags --match 'v*' --abbrev=0 "v${VERSION}^" 2>/dev/null || true)"
|
||||||
|
CL_RANGE="${PREV_TAG:+${PREV_TAG}..}v${VERSION}"
|
||||||
|
SINCE="$PREV_TAG"
|
||||||
|
else
|
||||||
|
CL_RANGE="$RANGE"
|
||||||
|
SINCE="$LAST_TAG"
|
||||||
|
fi
|
||||||
|
CL_SUBJECTS="$(git log --no-merges --format='%s' $CL_RANGE || true)"
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "## module-rust v${VERSION}"
|
||||||
|
echo
|
||||||
|
echo "Install from the website's Admin → Modules screen by pasting the URL of"
|
||||||
|
echo "\`module-rust-${VERSION}.json\`, or unpack the tarball onto the modules volume"
|
||||||
|
echo "as \`modules/rust/\`. Requires a core whose \`MODULE_API_VERSION\` satisfies"
|
||||||
|
echo "\`$(node -p "require('./module.json').coreApi")\`."
|
||||||
|
echo
|
||||||
|
echo "A Rust server also needs the other two halves of the bridge:"
|
||||||
|
echo "[Rust-Link](https://${GITEA_HOST}/RunicGateway/Rust-Link) (the sidecar) and"
|
||||||
|
echo "[Rust-Plugins](https://${GITEA_HOST}/RunicGateway/Rust-Plugins) (the Oxide/Carbon plugin)."
|
||||||
|
echo
|
||||||
|
FEATS="$(echo "$CL_SUBJECTS" | grep -E '^feat' || true)"
|
||||||
|
FIXES="$(echo "$CL_SUBJECTS" | grep -E '^(fix|perf)' || true)"
|
||||||
|
[ -n "$FEATS" ] && { echo "### Features"; echo "$FEATS" | sed 's/^/- /'; echo; }
|
||||||
|
[ -n "$FIXES" ] && { echo "### Fixes"; echo "$FIXES" | sed 's/^/- /'; echo; }
|
||||||
|
echo "### All changes"
|
||||||
|
if [ -n "$SINCE" ]; then echo "Since ${SINCE}:"; fi
|
||||||
|
echo "$CL_SUBJECTS" | sed 's/^/- /'
|
||||||
|
echo
|
||||||
|
echo "### Verifying this download"
|
||||||
|
echo
|
||||||
|
echo "Releases are **unsigned** — the \`sha256\` in \`module-rust-${VERSION}.json\` is the"
|
||||||
|
echo "trust anchor, and the website verifies it before unpacking."
|
||||||
|
echo
|
||||||
|
echo '```bash'
|
||||||
|
echo "sha256sum -c SHA256SUMS --ignore-missing"
|
||||||
|
echo '```'
|
||||||
|
} > dist/CHANGELOG.md
|
||||||
|
|
||||||
|
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "release=${RELEASE}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "reuse_tag=${REUSE_TAG}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "bump=${BUMP}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "==> release=${RELEASE} version=${VERSION} bump=${BUMP} declared=${DECLARED} last_tag=${LAST_TAG:-<none>}"
|
||||||
|
|
||||||
|
# Before anything is built or tagged, so a repo without secrets fails
|
||||||
|
# legibly rather than half-publishing: the tag push can succeed on the
|
||||||
|
# credential actions/checkout left in the local git config while the release
|
||||||
|
# API call 401s, leaving the repo tagged and unreleased.
|
||||||
|
- name: Verify release credentials are configured
|
||||||
|
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||||
|
env:
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -z "$(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" ]; then
|
||||||
|
echo "::error::Missing Actions secret REGISTRY_TOKEN (needs write:repository) on ${REPO}."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Release credentials present."
|
||||||
|
|
||||||
|
- name: Build the client chunk
|
||||||
|
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||||
|
run: |
|
||||||
|
npm ci --prefix client
|
||||||
|
npm run build --prefix client
|
||||||
|
|
||||||
|
# ── Assemble exactly what an operator's volume gets ──────────────────
|
||||||
|
#
|
||||||
|
# Stated as an INCLUDE list, not an exclude list. An exclude list ships
|
||||||
|
# whatever it forgot: the day someone adds `server/tools/` with a scratch
|
||||||
|
# credential in it, an exclude list packs it and nobody finds out.
|
||||||
|
#
|
||||||
|
# The list itself lives in `ci/bundle.json`, not here, because it has a
|
||||||
|
# second reader: `server/scripts/checkBundle.js` runs in PR checks and asks
|
||||||
|
# whether the list still covers everything `server/index.js` reaches. One
|
||||||
|
# declaration, two readers, so a new directory cannot go missing quietly.
|
||||||
|
- name: Assemble the bundle
|
||||||
|
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
VERSION="${{ steps.plan.outputs.version }}"
|
||||||
|
OUT="dist/module-rust-${VERSION}"
|
||||||
|
rm -rf "$OUT" && mkdir -p "$OUT"
|
||||||
|
|
||||||
|
# The manifest core reads — with the RELEASED version written into it.
|
||||||
|
# The committed `module.json` is a floor, not a record of the last
|
||||||
|
# release (see the header), so copying it verbatim would ship a bundle
|
||||||
|
# whose `installed_modules` row and admin screen disagree with the tag it
|
||||||
|
# came from. This is the one place the derived number becomes the
|
||||||
|
# module's own.
|
||||||
|
jq --arg v "$VERSION" '.version = $v' module.json > "$OUT/module.json"
|
||||||
|
|
||||||
|
# The two fragments, and the licence the code is under — a bundle that
|
||||||
|
# ships GPL code without its licence is not distributable.
|
||||||
|
for f in $(jq -r '.root[]' ci/bundle.json); do
|
||||||
|
cp "$f" "$OUT/"
|
||||||
|
done
|
||||||
|
|
||||||
|
# The server half, minus what never runs inside core's process. No
|
||||||
|
# node_modules: the shipped half declares no runtime dependencies, and
|
||||||
|
# check:bundle is what keeps that true.
|
||||||
|
mkdir -p "$OUT/server"
|
||||||
|
for d in $(jq -r '.server[]' ci/bundle.json); do
|
||||||
|
cp -r "server/$d" "$OUT/server/"
|
||||||
|
done
|
||||||
|
|
||||||
|
# The client half is the BUILT chunk only. `client/src` is source an
|
||||||
|
# operator has no use for and core will never read.
|
||||||
|
mkdir -p "$OUT/client/dist"
|
||||||
|
cp client/dist/entry.js "$OUT/client/dist/"
|
||||||
|
|
||||||
|
# Prove the bundle is loadable before it is published: these are the
|
||||||
|
# paths core's loader resolves out of module.json, and a release whose
|
||||||
|
# entry point is missing fails on an operator's box with a
|
||||||
|
# `startup_failed` row instead of here. The version assertion guards the
|
||||||
|
# rewrite above — a bundle that still carried the declared version would
|
||||||
|
# install under a number that is not the one it was released as.
|
||||||
|
node -e '
|
||||||
|
const fs = require("fs"), path = require("path");
|
||||||
|
const [root, want] = process.argv.slice(1);
|
||||||
|
const m = JSON.parse(fs.readFileSync(path.join(root, "module.json"), "utf8"));
|
||||||
|
if (m.version !== want) {
|
||||||
|
console.error(`bundle declares ${m.version}, but this is release ${want}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
for (const p of [m.server, m.schema, m.purge, m.client.entry, "swagger-fragment.json"]) {
|
||||||
|
if (!fs.existsSync(path.join(root, p))) { console.error("bundle is missing " + p); process.exit(1); }
|
||||||
|
}
|
||||||
|
console.log("bundle contents check: ok");
|
||||||
|
' "$OUT" "$VERSION"
|
||||||
|
|
||||||
|
# ── And that it can actually LOAD ─────────────────────────────────
|
||||||
|
#
|
||||||
|
# The check above stats the paths `module.json` declares, which is a real
|
||||||
|
# question but a shallow one: Module-uo's v1.0.0 passed exactly that and
|
||||||
|
# was still missing `server/commands/`, because a file reached only by a
|
||||||
|
# require inside `register()` is named nowhere in `module.json`. This
|
||||||
|
# resolves every relative require in the assembled tree and asserts the
|
||||||
|
# target is in it — asked of the artifact, so it also catches a copy that
|
||||||
|
# half failed or a list naming a path that has since moved.
|
||||||
|
#
|
||||||
|
# Run from the SOURCE tree (`server/scripts/` never ships) against the
|
||||||
|
# assembled bundle.
|
||||||
|
node server/scripts/checkBundle.js --bundle "$OUT"
|
||||||
|
|
||||||
|
tar -C dist -czf "dist/module-rust-${VERSION}.tar.gz" "module-rust-${VERSION}"
|
||||||
|
rm -rf "$OUT"
|
||||||
|
|
||||||
|
SHA="$(sha256sum "dist/module-rust-${VERSION}.tar.gz" | cut -d' ' -f1)"
|
||||||
|
SIZE="$(stat -c%s "dist/module-rust-${VERSION}.tar.gz")"
|
||||||
|
|
||||||
|
# The install manifest. Same shape as the installer's bundle JSON — a
|
||||||
|
# per-asset sha256 fetched over HTTPS, no signatures — because that is
|
||||||
|
# the model this project already has and a second one would be a second
|
||||||
|
# thing to get right (MODULE_SYSTEM.md §1.11).
|
||||||
|
jq -n \
|
||||||
|
--arg id "$(node -p "require('./module.json').id")" \
|
||||||
|
--arg name "$(node -p "require('./module.json').name")" \
|
||||||
|
--arg version "$VERSION" \
|
||||||
|
--arg coreApi "$(node -p "require('./module.json').coreApi")" \
|
||||||
|
--arg artifact "module-rust-${VERSION}.tar.gz" \
|
||||||
|
--arg sha256 "$SHA" \
|
||||||
|
--argjson size "$SIZE" \
|
||||||
|
--arg url "https://${GITEA_HOST}/${REPO}/releases/download/v${VERSION}/module-rust-${VERSION}.tar.gz" \
|
||||||
|
'{schema:1, id:$id, name:$name, version:$version, coreApi:$coreApi,
|
||||||
|
artifact:$artifact, url:$url, sha256:$sha256, size:$size}' \
|
||||||
|
> "dist/module-rust-${VERSION}.json"
|
||||||
|
|
||||||
|
echo "${SHA} module-rust-${VERSION}.tar.gz" > dist/SHA256SUMS
|
||||||
|
cat "dist/module-rust-${VERSION}.json"
|
||||||
|
|
||||||
|
# Skipped on a recovery run: the tag is already there and is the thing being
|
||||||
|
# published against.
|
||||||
|
- name: Tag the release
|
||||||
|
if: ${{ steps.plan.outputs.release == 'true' && steps.plan.outputs.reuse_tag != 'true' }}
|
||||||
|
env:
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
TAG="${{ steps.plan.outputs.tag }}"
|
||||||
|
git config user.name 'Runic Gateway CI'
|
||||||
|
git config user.email 'ci@whitlocktech.net'
|
||||||
|
git tag -a "$TAG" -m "module-rust ${TAG}"
|
||||||
|
git push origin "$TAG"
|
||||||
|
|
||||||
|
- name: Create the Gitea release and upload the bundle
|
||||||
|
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||||
|
env:
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
TAG="${{ steps.plan.outputs.tag }}"
|
||||||
|
VERSION="${{ steps.plan.outputs.version }}"
|
||||||
|
API="https://${GITEA_HOST}/api/v1/repos/${REPO}"
|
||||||
|
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
|
||||||
|
|
||||||
|
REL_ID="$(curl -sSf -X POST "${API}/releases" \
|
||||||
|
-H "Authorization: token ${CI_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$(jq -n --arg tag "$TAG" --arg body "$(cat dist/CHANGELOG.md)" \
|
||||||
|
'{tag_name:$tag, name:$tag, body:$body, draft:false, prerelease:false}')" \
|
||||||
|
| jq -r '.id')"
|
||||||
|
echo "Created release ${TAG} (id=${REL_ID})"
|
||||||
|
|
||||||
|
for f in "module-rust-${VERSION}.tar.gz" "module-rust-${VERSION}.json" SHA256SUMS; do
|
||||||
|
curl -sSf -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \
|
||||||
|
-H "Authorization: token ${CI_TOKEN}" \
|
||||||
|
-F "attachment=@dist/${f}" >/dev/null
|
||||||
|
echo " uploaded ${f}"
|
||||||
|
done
|
||||||
187
README.md
Normal file
187
README.md
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
# Module-Rust
|
||||||
|
|
||||||
|
The **[Rust](https://rust.facepunch.com/) module** for the Runic Gateway platform: everything that
|
||||||
|
makes a Runic Gateway site a site *for* Rust. It installs into a website core as
|
||||||
|
`modules/rust/` and is the platform's second game module, after
|
||||||
|
[`Module-uo`](https://gitea.whitlocktech.com/RunicGateway/Module-uo).
|
||||||
|
|
||||||
|
It is also the first module built from the
|
||||||
|
[Integration Kit](https://gitea.whitlocktech.com/RunicGateway/Integration-kit) rather than extracted
|
||||||
|
from the website — which makes it the kit's acceptance test from the inside.
|
||||||
|
|
||||||
|
**The repository name is not the module id.** This ships a module whose `id` is `rust`, because the
|
||||||
|
contract requires `id` to equal the directory core loads it from (`modules/rust/`), and that id is
|
||||||
|
the prefix of every table and every mount.
|
||||||
|
|
||||||
|
## What it is, in one diagram
|
||||||
|
|
||||||
|
```
|
||||||
|
Rust server + Oxide (RunicGateway/Rust-Plugins)
|
||||||
|
│ loopback TCP, the plugin dials out
|
||||||
|
▼
|
||||||
|
rust-link sidecar (RunicGateway/Rust-Link) one per game server
|
||||||
|
│ HTTPS + WebSocket, bearer token
|
||||||
|
▼
|
||||||
|
this module, inside a website core one client per server
|
||||||
|
│ same-origin JSON
|
||||||
|
▼
|
||||||
|
browser · Android app
|
||||||
|
```
|
||||||
|
|
||||||
|
**One server, one sidecar.** A community running six Rust servers runs six pairs and configures six
|
||||||
|
rows here; the website core never learns there is more than one.
|
||||||
|
|
||||||
|
## What ships today
|
||||||
|
|
||||||
|
| Surface | Route |
|
||||||
|
|---|---|
|
||||||
|
| Public | `GET /api/v1/public/rust/servers` — every server and what it last reported |
|
||||||
|
| Public | `GET …/servers/:id` — one server, or a `404`; the only route under `:id` that can say a server does not exist |
|
||||||
|
| Public | `GET …/servers/:id/events` — the feed, served from a default-deny allowlist (`server/catalogue.js`) |
|
||||||
|
| Public | `GET …/servers/:id/leaderboard` — per wipe, or all-time as those rows summed |
|
||||||
|
| Public | `GET …/servers/:id/wipes` and `…/online` |
|
||||||
|
| Player | `GET /api/v1/player/rust/servers` — the server list, on the authenticated tier |
|
||||||
|
| Admin | `GET/PUT/DELETE /api/v1/admin/rust/servers` and `POST …/:id/test` |
|
||||||
|
| Pages | `/rust` — the server list, and the module's landing page |
|
||||||
|
| Pages | `/rust/servers/:id` — one server: feed, leaderboard, who is on, wipes |
|
||||||
|
| Slot | `site.footer.status` — a live server/player count in core's footer |
|
||||||
|
|
||||||
|
Every page reads this module's own tables and never calls a game server, which is what lets the
|
||||||
|
whole surface render while every server in the fleet is off. Tab, feed filter, wipe and leaderboard
|
||||||
|
sort all live in the URL, so any view of it is a link.
|
||||||
|
|
||||||
|
Seven tables: `rust_servers` (configuration), `rust_server_state` and `rust_presence` (observed
|
||||||
|
state), `rust_wipes`, `rust_players`, `rust_player_wipe_stats` and `rust_gather_totals` (the record a
|
||||||
|
wipe does not erase), plus the bounded `rust_events` window and the `rust_ingest_cursor`.
|
||||||
|
|
||||||
|
The rest of the module — identity, site-owned permissions, Teams from Rust's clans, notifications,
|
||||||
|
events, the live map, Discord commands — arrives phase by phase. **Nothing is registered before it
|
||||||
|
has something behind it:** a declared trigger nothing emits and a declared slot nothing fills are
|
||||||
|
both surfaces an operator can configure and then wait on, which is worse than an absent one.
|
||||||
|
|
||||||
|
### What a client feature-detects on
|
||||||
|
|
||||||
|
`module.json` declares six capability strings, and `GET /api/v1/public/modules` hands them to any
|
||||||
|
client that asks — the website's own nav, and the Android app (`docs/modules/rust/PLAN.md` R10).
|
||||||
|
Five of them name a surface: `servers`, `killfeed`, `leaderboard`, `presence`, `wipes`.
|
||||||
|
|
||||||
|
The sixth is `rust`, and it names **the module itself**. It looks redundant beside `id`, and it is
|
||||||
|
not, for two reasons worth writing down before somebody tidies it away:
|
||||||
|
|
||||||
|
- **A client that asks "is this module installed" has nowhere else to ask.** Core flattens every
|
||||||
|
started module's capabilities into one list, so `servers` alone is a word another module could
|
||||||
|
declare tomorrow and silently reveal this one's screens. `rust` is the string that can only mean
|
||||||
|
this module, and it is the single gate a whole navigation group hangs on — exactly the job `shard`
|
||||||
|
does for `module-uo`.
|
||||||
|
- **`id` answers a different question.** It is a *mount prefix* (§2.1 requires it to equal the
|
||||||
|
directory core loads the module from), and `MODULE_API.md` §2.9 is explicit that a client must
|
||||||
|
never infer a route from a capability. Gating on `id` would quietly make the two the same thing,
|
||||||
|
and the day a client builds `/<id>/servers` from it, the contract that lets this module move its
|
||||||
|
own pages is gone.
|
||||||
|
|
||||||
|
An unknown capability is absent, and no route is ever derived from one.
|
||||||
|
|
||||||
|
## Build and check
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm ci --prefix server && npm test --prefix server
|
||||||
|
npm run check:imports --prefix server
|
||||||
|
npm run check:bundle --prefix server
|
||||||
|
npm run check:swagger --prefix server
|
||||||
|
npm ci --prefix client && npm run build --prefix client
|
||||||
|
npm run check:externals --prefix client && npm test --prefix client
|
||||||
|
```
|
||||||
|
|
||||||
|
**Build the client BEFORE running its tests** — two of them read the built chunk and skip when there
|
||||||
|
is none, so a run in the other order passes while asking nothing about the artifact that ships.
|
||||||
|
|
||||||
|
Regenerate the OpenAPI fragment whenever a route or an annotation changes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run swagger --prefix server # writes swagger-fragment.json; commit it
|
||||||
|
```
|
||||||
|
|
||||||
|
`.gitea/workflows/pr-checks.yml` runs all of the above on every pull request, plus one job this
|
||||||
|
machine cannot run on its own: **frozen-manifest** clones core at the sha pinned in
|
||||||
|
[`ci/core-ref.json`](ci/core-ref.json), generates its route table without this module and then with
|
||||||
|
it, and takes the difference. That difference is the URL surface this module serves — checked
|
||||||
|
against the committed [`routes.manifest.json`](routes.manifest.json), against the OpenAPI fragment
|
||||||
|
in both directions, and against the rule that **a module may only add**. It is the only thing that
|
||||||
|
can see whether `/rust` collides with one of the routes core mounts at a tier root (`/status`,
|
||||||
|
`/version`), which the loader's own collision probe cannot find.
|
||||||
|
|
||||||
|
## How it reaches an operator
|
||||||
|
|
||||||
|
**An operator never builds anything.** A release is not source: it is the directory core's loader
|
||||||
|
expects at `modules/rust/`, already assembled — the prebuilt client chunk, the schema fragment and
|
||||||
|
the OpenAPI fragment, packed as they will be unpacked.
|
||||||
|
|
||||||
|
**Every merge to `main` carrying a releasable commit publishes a bundle.** The next version is
|
||||||
|
computed from conventional-commit subjects since the newest `v*` tag, as in `link`, `installer` and
|
||||||
|
`Module-uo`: `feat!:` or `BREAKING CHANGE` is a major, `feat:` a minor, `fix:` or `perf:` a patch,
|
||||||
|
and a `main` that gained none of those cuts no release. The number that ships is the **tag**, and CI
|
||||||
|
writes it into the `module.json` inside the bundle. `module.json`'s version survives as a **floor**:
|
||||||
|
name a version there above the newest tag and that version releases, which is how you overrule the
|
||||||
|
subjects. For a change with nothing releasable behind it — a widened `coreApi`, a new mount, a
|
||||||
|
capability — run the **Release** workflow by hand (Actions → Release → Run workflow).
|
||||||
|
|
||||||
|
Each release carries:
|
||||||
|
|
||||||
|
| Asset | What it is |
|
||||||
|
|---|---|
|
||||||
|
| `module-rust-<version>.tar.gz` | the directory core expects at `modules/rust/`, already assembled |
|
||||||
|
| `module-rust-<version>.json` | the install manifest: id, version, `coreApi`, the artifact's URL, size and **`sha256`** |
|
||||||
|
| `SHA256SUMS` | the same hash, in the shape every other repo here publishes |
|
||||||
|
|
||||||
|
Releases are **unsigned**; the `sha256` is the trust anchor, and the website verifies it before
|
||||||
|
unpacking. That is the model `installer`'s bundles already use, and a second trust model would be a
|
||||||
|
second thing to get right.
|
||||||
|
|
||||||
|
The tarball is assembled from an **include** list ([`ci/bundle.json`](ci/bundle.json)), never an
|
||||||
|
exclude list — an exclude list ships whatever it forgot. Tests, scripts, `client/src`, `ci/` and the
|
||||||
|
dev dependencies are not in it. It carries **no `node_modules`**, because the shipped half declares
|
||||||
|
no runtime dependencies: everything it needs arrives on `ctx`. `npm run check:bundle` holds both
|
||||||
|
halves of that — that the list still covers every file `server/index.js` can reach, and that no
|
||||||
|
runtime dependency has appeared without the release learning to pack it.
|
||||||
|
|
||||||
|
## Install it into a core
|
||||||
|
|
||||||
|
**From a release**, which is the supported path: in Admin → Modules, paste the URL of that release's
|
||||||
|
`module-rust-<version>.json`, and restart when the panel offers. Core fetches the manifest, checks
|
||||||
|
every URL and redirect hop against its own host allowlist, streams the artifact under a byte cap
|
||||||
|
while hashing it, verifies the `sha256`, inspects the archive in full before unpacking it to a
|
||||||
|
temporary directory, and only then moves it into `modules/rust/`. Nothing is written into the
|
||||||
|
modules directory until every check has passed. The allowlist must contain
|
||||||
|
`gitea.whitlocktech.com` — it is seeded from `MODULE_SOURCE_HOSTS` on a fresh install and is
|
||||||
|
DB-owned from then on, edited on that same screen. **An empty allowlist forbids every install rather
|
||||||
|
than permitting all of them.**
|
||||||
|
|
||||||
|
**From a working tree**, for development: copy the whole tree to `<website>/modules/rust/` and
|
||||||
|
restart. **Copy, do not symlink** — the loader lists directory entries and asks each whether it is a
|
||||||
|
directory; a symlink answers no and the module is skipped in complete silence.
|
||||||
|
|
||||||
|
Either way, the module appears when the process restarts: the volume is read at require time.
|
||||||
|
|
||||||
|
Then, in Admin → Rust, add a server: its name, the sidecar's base URL, and the token the sidecar
|
||||||
|
printed on first start (`rust-link-sidecar --print-config`). **The token is write-only** — it is
|
||||||
|
stored encrypted through core's own secret box and never returned to any client; the panel reports
|
||||||
|
only whether one is set.
|
||||||
|
|
||||||
|
`POST /api/v1/admin/rust/servers/:id/test` probes a sidecar and reports what came back in one word.
|
||||||
|
That is the route that tells a wrong URL from a wrong token from a mismatched protocol version —
|
||||||
|
all three present as "the site says my server is offline" and each has a different fix.
|
||||||
|
|
||||||
|
## The protocol is a contract
|
||||||
|
|
||||||
|
`PROTOCOL_VERSION` in `server/sidecarClient.js` is sent on every request as `X-RustLink-Version`,
|
||||||
|
and a sidecar speaking a different one answers `409` rather than serving something this module will
|
||||||
|
mis-parse. It must agree with the sidecar's own constant and with `overlay.toml` in the plugin repo.
|
||||||
|
|
||||||
|
Canonical spec:
|
||||||
|
[`docs/rust-link/PROTOCOL.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/rust-link/PROTOCOL.md).
|
||||||
|
The module's own design of record is
|
||||||
|
[`docs/modules/rust/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/modules/rust/PLAN.md).
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
GPL-3.0-or-later. See [LICENSE.md](LICENSE.md).
|
||||||
50
ci/bundle.json
Normal file
50
ci/bundle.json
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"$comment": [
|
||||||
|
"What a release copies into the bundle, declared ONCE. Read by .gitea/workflows/release.yml when",
|
||||||
|
"it assembles the tarball, and by server/scripts/checkBundle.js when CI asks whether that list",
|
||||||
|
"still covers everything the module's entry point can reach.",
|
||||||
|
"",
|
||||||
|
"This is an INCLUDE list on purpose. An exclude list ships whatever it forgot: the day someone",
|
||||||
|
"adds server/tools/ with a scratch credential in it, an exclude list packs it and nobody finds",
|
||||||
|
"out. The cost of that choice is that a new top-level directory silently drops OUT of every",
|
||||||
|
"release instead — which is exactly what happened to Module-uo between v0.3.0 and v1.0.0, where",
|
||||||
|
"server/commands/ arrived with a cutover, the list did not learn about it, and the module",
|
||||||
|
"installed and then died at the register stage on the operator's box. checkBundle.js exists so",
|
||||||
|
"that cannot happen twice, and it runs on the PR that adds the directory.",
|
||||||
|
"",
|
||||||
|
"server[] entries are paths under server/; root[] and generated[] are paths under the module",
|
||||||
|
"root.",
|
||||||
|
"",
|
||||||
|
"node_modules is NOT here, and its absence is asserted rather than assumed: this module declares",
|
||||||
|
"no runtime dependencies (everything the shipped half needs arrives on ctx), so the release runs",
|
||||||
|
"no npm ci and packs no dependency tree. checkBundle.js fails the PR that adds a `dependencies`",
|
||||||
|
"entry to server/package.json without also teaching the release to pack it — because a module",
|
||||||
|
"whose bundle silently lacks its own dependency fails the same way the missing directory did.",
|
||||||
|
"",
|
||||||
|
"generated[] ships but is not copied — release.yml writes module.json through jq to stamp the",
|
||||||
|
"released version into it, since the committed one is a floor rather than a record of the last",
|
||||||
|
"release. It is listed because server/index.js requires it, and a check that did not know it",
|
||||||
|
"ships would report the module's own manifest as missing from the bundle."
|
||||||
|
],
|
||||||
|
"server": [
|
||||||
|
"boot.js",
|
||||||
|
"catalogue.js",
|
||||||
|
"core.js",
|
||||||
|
"db",
|
||||||
|
"index.js",
|
||||||
|
"ingest.js",
|
||||||
|
"model",
|
||||||
|
"package.json",
|
||||||
|
"permSync.js",
|
||||||
|
"router",
|
||||||
|
"sidecarClient.js"
|
||||||
|
],
|
||||||
|
"root": [
|
||||||
|
"swagger-fragment.json",
|
||||||
|
"LICENSE.md",
|
||||||
|
"README.md"
|
||||||
|
],
|
||||||
|
"generated": [
|
||||||
|
"module.json"
|
||||||
|
]
|
||||||
|
}
|
||||||
6
ci/core-ref.json
Normal file
6
ci/core-ref.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/rust and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves, because a mount prefix is a string in server/index.js and a documented path is a string in a JSON file, and whether those name the same URL is a fact about a running core. It also answers the blind spot phase 1 had to check by hand: core mounts several routes at the TIER ROOT (/status, /version), which the loader's collision probe cannot see, so /rust being free is asserted here by a core rather than by a reading. Pinned rather than tracking a branch on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together. This module needs MODULE_API 1.10.0 (module.json's coreApi is ^1.10.0), which the Event System cutover put on `main` — so unlike Module-uo, which spent the Event System window pinned to `edge`, this repo starts pinned to `main` and should stay there unless it comes to depend on a contract member that has not shipped yet.",
|
||||||
|
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
|
||||||
|
"ref": "efa9db73304552dd8bb7a84030b258c6320f79f7",
|
||||||
|
"refName": "main @ MODULE_API 1.10.0, the Asset Bridge cutover 2 of 5 (website#202)"
|
||||||
|
}
|
||||||
1792
client/package-lock.json
generated
Normal file
1792
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
client/package.json
Normal file
24
client/package.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "rust-module-client",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Client half of the Rust module — a prebuilt ESM chunk core injects into its own SPA",
|
||||||
|
"license": "GPL-3.0-or-later",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "vite build",
|
||||||
|
"test": "node --test",
|
||||||
|
"check:externals": "node scripts/checkExternals.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"//dependencies": "Deliberately none that ship. react, react-dom/client, react/jsx-runtime and react-router-dom are aliased to the shims in src/shim/ and arrive at runtime on window.__rg - there is exactly one React in the page and core owns it (MODULE_API.md 3.2, 3.6). They are devDependencies so that Vite and the JSX transform can resolve them during the build, and for no other reason.",
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^4.3.2",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.26.2",
|
||||||
|
"vite": "^5.4.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
172
client/scripts/checkExternals.js
Normal file
172
client/scripts/checkExternals.js
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// ── §5.1's client half — what stayed a bare import in the built chunk ──────
|
||||||
|
//
|
||||||
|
// The server half's boundary check reads source. The client half's has to read
|
||||||
|
// the BUILD OUTPUT, because the failure it exists to catch is invisible in
|
||||||
|
// source: `import { useState } from 'react'` is correct in every file, and
|
||||||
|
// whether it ends up as core's React or as a second copy welded into the chunk
|
||||||
|
// is decided by vite.config.js's aliases. A missed alias changes nothing you can
|
||||||
|
// see until a hook throws in the browser.
|
||||||
|
//
|
||||||
|
// So: build, then ask the artifact two questions.
|
||||||
|
//
|
||||||
|
// 1. **Is there a bare import left?** There must not be. Aliased shims are
|
||||||
|
// bundled, so a surviving bare specifier means an alias missed and
|
||||||
|
// `external` caught it — the loud failure the config prefers, but still a
|
||||||
|
// failure, and better found here than by a browser refusing to load.
|
||||||
|
// 2. **Did a shared dependency get bundled?** React's own source has
|
||||||
|
// fingerprints that no module of ours would contain by accident. Finding
|
||||||
|
// one means the chunk carries a second React, which is the silent version
|
||||||
|
// of the same mistake and the one worth the fingerprint check.
|
||||||
|
//
|
||||||
|
// Run after `npm run build`, in CI, on the artifact that ships.
|
||||||
|
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const CHUNK = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'dist', 'entry.js')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which characters of the chunk are inside a string, template or comment.
|
||||||
|
*
|
||||||
|
* **A check that reads code with a regexp fails on code that talks about
|
||||||
|
* itself.** The first real chunk this script ever saw — slice 3's, the first
|
||||||
|
* with any content in it — was rejected for importing `" }),\n !l && …`,
|
||||||
|
* because a button reading "Approve and import" put the token `import`
|
||||||
|
* immediately before a quote and the pattern could not tell that from a
|
||||||
|
* statement. Slice 0's chunk was 0.2 kB and this branch had never run against
|
||||||
|
* anything.
|
||||||
|
*
|
||||||
|
* The server half hit the same wall from the other side and answered it the same
|
||||||
|
* way (`server/scripts/checkImports.js`): a character walk, not a cleverer
|
||||||
|
* regexp. There is no regexp that distinguishes a keyword from the same letters
|
||||||
|
* inside a string, because that distinction is a property of the parse.
|
||||||
|
*
|
||||||
|
* A mask rather than a rewrite, because the two halves of a real import — the
|
||||||
|
* keyword and the specifier — sit on opposite sides of the boundary: the keyword
|
||||||
|
* must be OUTSIDE a string and the specifier must be a string. Blanking strings
|
||||||
|
* would take the answer with the noise.
|
||||||
|
*/
|
||||||
|
export function stringMask(src) {
|
||||||
|
const inString = new Uint8Array(src.length)
|
||||||
|
let i = 0
|
||||||
|
while (i < src.length) {
|
||||||
|
const c = src[i]
|
||||||
|
const two = src.slice(i, i + 2)
|
||||||
|
if (two === '//') {
|
||||||
|
const nl = src.indexOf('\n', i)
|
||||||
|
const end = nl === -1 ? src.length : nl
|
||||||
|
inString.fill(1, i, end)
|
||||||
|
i = end
|
||||||
|
} else if (two === '/*') {
|
||||||
|
const close = src.indexOf('*/', i + 2)
|
||||||
|
const end = close === -1 ? src.length : close + 2
|
||||||
|
inString.fill(1, i, end)
|
||||||
|
i = end
|
||||||
|
} else if (c === '"' || c === "'" || c === '`') {
|
||||||
|
// The opening quote itself stays unmasked: a specifier is read starting
|
||||||
|
// at its quote, and the regexp below anchors on that.
|
||||||
|
i += 1
|
||||||
|
while (i < src.length && src[i] !== c) {
|
||||||
|
// A backslash escapes the next character, including the closing quote.
|
||||||
|
const step = src[i] === '\\' ? 2 : 1
|
||||||
|
inString.fill(1, i, Math.min(i + step, src.length))
|
||||||
|
i += step
|
||||||
|
}
|
||||||
|
i += 1
|
||||||
|
} else {
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return inString
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static and dynamic imports that survived into the output. A relative or
|
||||||
|
// absolute specifier is a chunk that was split, which this build does not do —
|
||||||
|
// `lib` mode with one entry emits one file — so anything here is a bare name.
|
||||||
|
//
|
||||||
|
// **This pattern used to require whitespace after `import`, and so could not see
|
||||||
|
// the one shape the build actually emits.** Minified Rollup output is
|
||||||
|
// `import{useState}from"react"`, with no space anywhere in it; the old
|
||||||
|
// `import\s+[^'"]*?from` needed at least one, fell through to the bare-specifier
|
||||||
|
// alternative, met `{` instead of a quote and matched nothing. A bare named
|
||||||
|
// import — the most likely way for an alias to miss — would have passed this
|
||||||
|
// check silently. It was found by writing the test for the false POSITIVE above
|
||||||
|
// it, which is the argument for testing a check against both answers.
|
||||||
|
//
|
||||||
|
// `(?:^|[^\w$.])` rather than a whitespace class, so `a.import(x)` and
|
||||||
|
// `myimport"x"` are excluded for the right reason: `import` must not be preceded
|
||||||
|
// by an identifier character or a dot. `[^'"()]*?` cannot swallow a dynamic
|
||||||
|
// import's parenthesis.
|
||||||
|
const IMPORTS = /(?:^|[^\w$.])import\s*(?:\(\s*|[^'"()]*?from\s*)?['"]([^'"]+)['"]/g
|
||||||
|
|
||||||
|
/** Every bare specifier the chunk still imports at runtime. */
|
||||||
|
export function bareImports(chunk) {
|
||||||
|
const masked = stringMask(chunk)
|
||||||
|
const bare = new Set()
|
||||||
|
for (const match of chunk.matchAll(IMPORTS)) {
|
||||||
|
// Where the `import` keyword itself starts — one past the leading delimiter,
|
||||||
|
// unless the match began at position 0.
|
||||||
|
const keywordAt = match.index + (match[0].startsWith('import') ? 0 : 1)
|
||||||
|
if (masked[keywordAt]) continue // the letters, inside a string. Not a statement.
|
||||||
|
const specifier = match[1]
|
||||||
|
if (!specifier.startsWith('.') && !specifier.startsWith('/')) bare.add(specifier)
|
||||||
|
}
|
||||||
|
return [...bare]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fingerprints from the shared libraries' own source. Each is a string those
|
||||||
|
// packages ship and this module has no other reason to contain.
|
||||||
|
//
|
||||||
|
// These are matched against the RAW chunk, deliberately unmasked: a bundled
|
||||||
|
// library's source arrives as code AND as its own error-message strings, and
|
||||||
|
// masking would discard half the evidence. The direction of the risk is opposite
|
||||||
|
// to the import check's — here a false positive is a fingerprint too generic,
|
||||||
|
// which is a fixable choice of probe, not a property of the parse.
|
||||||
|
const BUNDLED = [
|
||||||
|
{ what: 'react', probe: 'react.development.js' },
|
||||||
|
{ what: 'react', probe: 'Invalid hook call' },
|
||||||
|
{ what: 'react-dom', probe: 'react-dom.development.js' },
|
||||||
|
{ what: 'react-router-dom', probe: 'useRoutes() may be used only in the context of a <Router> component' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Every problem with this chunk, as sentences. Empty means it ships. */
|
||||||
|
export function problemsWith(chunk) {
|
||||||
|
const problems = []
|
||||||
|
const bare = bareImports(chunk)
|
||||||
|
if (bare.length) {
|
||||||
|
problems.push(
|
||||||
|
`the chunk still imports ${bare.map((s) => `"${s}"`).join(', ')} — ` +
|
||||||
|
'nothing can resolve a bare specifier in the browser without an import map, ' +
|
||||||
|
'and CSP forbids one. Alias it to a shim in vite.config.js (MODULE_API.md §3.6).',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
for (const { what, probe } of BUNDLED) {
|
||||||
|
if (chunk.includes(probe)) {
|
||||||
|
problems.push(
|
||||||
|
`the chunk appears to BUNDLE ${what} (found ${JSON.stringify(probe)}). ` +
|
||||||
|
'There is exactly one React in the page and core owns it — a second copy ' +
|
||||||
|
'loads fine and then fails at the first hook (MODULE_API.md §3.2).',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return problems
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only when run as a script. Importing this from a test must not read a chunk
|
||||||
|
// that may not have been built, and must not call process.exit.
|
||||||
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||||
|
if (!fs.existsSync(CHUNK)) {
|
||||||
|
console.error(`No chunk at ${CHUNK} — run \`npm run build\` first.`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
const problems = problemsWith(fs.readFileSync(CHUNK, 'utf8'))
|
||||||
|
if (problems.length) {
|
||||||
|
console.error('\nThe built chunk breaks the shared-dependency rule:\n')
|
||||||
|
for (const p of problems) console.error(` - ${p}\n`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
const kb = (fs.statSync(CHUNK).size / 1024).toFixed(1)
|
||||||
|
console.log(`OK — dist/entry.js (${kb} kB) has no bare imports and bundles no shared dependency.`)
|
||||||
|
}
|
||||||
195
client/src/api.js
Normal file
195
client/src/api.js
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
// ── This module's own API bindings ────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Core hands out the request PRIMITIVE and nothing above it (MODULE_API.md
|
||||||
|
// §3.5): same-origin `/api/v1`, cookies included, JSON in and out, and an
|
||||||
|
// `ApiError` thrown on any non-2xx. The paths are this module's, because the
|
||||||
|
// routes at the other end are — `server/router/**` in this repo serves them.
|
||||||
|
//
|
||||||
|
// **Do not build your own fetch wrapper.** The primitive is what carries the
|
||||||
|
// session cookie, the CSRF handling and the error shape core's `ErrorState`
|
||||||
|
// knows how to render. A module that calls `fetch` directly gets none of that
|
||||||
|
// and finds out one page at a time.
|
||||||
|
//
|
||||||
|
// Keeping the bindings in one file, ordered the way the routers are, is
|
||||||
|
// convention rather than contract — but the two halves of every call live in
|
||||||
|
// different directories and nothing checks them against each other, so anything
|
||||||
|
// that makes a mismatch easy to see is worth doing.
|
||||||
|
|
||||||
|
import rg from './core.js'
|
||||||
|
|
||||||
|
const { request: req, BASE } = rg.api
|
||||||
|
|
||||||
|
// ── public ────────────────────────────────────────────────────────────────
|
||||||
|
// Token-free, same-origin reads. Paths are relative to `/api/v1`, so this hits
|
||||||
|
// `/api/v1/public/rust/servers` — the route `server/router/public/rust.router.js`
|
||||||
|
// registers under the `/rust` prefix `module.json` declares.
|
||||||
|
export const servers = {
|
||||||
|
list: () => req('/public/rust/servers'),
|
||||||
|
|
||||||
|
// One server, and the only route under `/servers/:id` that can answer "no such
|
||||||
|
// server": the four below answer an empty list for an id nobody ever
|
||||||
|
// configured, because an unknown server genuinely has no events.
|
||||||
|
get: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}`),
|
||||||
|
|
||||||
|
// `kind` is a comma-separated list and `wipe` a wipe id; both are optional and
|
||||||
|
// both are built here rather than in a page, so the query string this module
|
||||||
|
// sends exists in one file.
|
||||||
|
events: (id, { kinds = null, wipe = null, limit = null } = {}) =>
|
||||||
|
req(`/public/rust/servers/${encodeURIComponent(id)}/events${query({
|
||||||
|
kind: kinds && kinds.length ? kinds.join(',') : null,
|
||||||
|
wipe,
|
||||||
|
limit,
|
||||||
|
})}`),
|
||||||
|
|
||||||
|
leaderboard: (id, { wipe = null, sort = null, limit = null } = {}) =>
|
||||||
|
req(`/public/rust/servers/${encodeURIComponent(id)}/leaderboard${query({ wipe, sort, limit })}`),
|
||||||
|
|
||||||
|
wipes: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}/wipes`),
|
||||||
|
|
||||||
|
online: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}/online`),
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A query string from the parameters that have a value, or `''`.
|
||||||
|
*
|
||||||
|
* **An absent parameter must be absent, not empty.** `?wipe=` is not the same
|
||||||
|
* question as no `wipe` at all — the first asks for a wipe whose id is the empty
|
||||||
|
* string — and a page that sends one because a `<select>` is on "All time" gets
|
||||||
|
* an empty leaderboard and no error.
|
||||||
|
*/
|
||||||
|
function query(params) {
|
||||||
|
const search = new URLSearchParams()
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
if (value !== null && value !== undefined && value !== '') search.set(key, String(value))
|
||||||
|
}
|
||||||
|
const string = search.toString()
|
||||||
|
return string ? `?${string}` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── player ────────────────────────────────────────────────────────────────
|
||||||
|
// The same list, on the authenticated tier. It exists so that per-player detail
|
||||||
|
// can be added at an address clients are already calling; today the two answers
|
||||||
|
// are identical and the server delegates to one model so they cannot drift.
|
||||||
|
export const playerServers = {
|
||||||
|
list: () => req('/player/rust/servers'),
|
||||||
|
}
|
||||||
|
|
||||||
|
// R1's identity link, from the signed-in player's side.
|
||||||
|
//
|
||||||
|
// **The code is the whole of what goes up.** The site has no idea which server
|
||||||
|
// minted it — nothing in six characters says — so the server half asks each
|
||||||
|
// configured server in turn (D24). A page that asked the player to pick would be
|
||||||
|
// asking them a question the site can answer itself, and a wrong pick would come
|
||||||
|
// back indistinguishable from a wrong code.
|
||||||
|
export const playerLinks = {
|
||||||
|
list: () => req('/player/rust/links'),
|
||||||
|
confirm: (code) => req('/player/rust/link', { method: 'POST', body: { code } }),
|
||||||
|
remove: (steamId) =>
|
||||||
|
req(`/player/rust/links/${encodeURIComponent(steamId)}`, { method: 'DELETE' }),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── admin ─────────────────────────────────────────────────────────────────
|
||||||
|
// **`sidecarToken` goes up and never comes back.** The list answers `hasToken`,
|
||||||
|
// and a save that omits the field leaves the stored credential alone — so an
|
||||||
|
// admin form must send it only when the operator typed one, rather than sending
|
||||||
|
// its own empty field on every save.
|
||||||
|
export const admin = {
|
||||||
|
listServers: () => req('/admin/rust/servers'),
|
||||||
|
saveServer: (id, body) =>
|
||||||
|
req(`/admin/rust/servers/${encodeURIComponent(id)}`, { method: 'PUT', body }),
|
||||||
|
deleteServer: (id) =>
|
||||||
|
req(`/admin/rust/servers/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||||
|
testServer: (id) =>
|
||||||
|
req(`/admin/rust/servers/${encodeURIComponent(id)}/test`, { method: 'POST' }),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── admin · permissions (R2) ──────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The authoring surface. Every call here writes to the SITE, and none of them
|
||||||
|
// reaches a game server — the mirror's own loop does that on its own cadence.
|
||||||
|
// `sync` is the exception and says so in its name: it runs the pass now and
|
||||||
|
// answers with what each server reported, which is the only call on this screen
|
||||||
|
// that can be slow or fail because a game host is down.
|
||||||
|
//
|
||||||
|
// A write is followed by a re-read rather than a local edit of the model: what
|
||||||
|
// the screen is showing is partly the game's answer, and the honest way to learn
|
||||||
|
// the new one is to ask.
|
||||||
|
export const adminPermissions = {
|
||||||
|
overview: () => req('/admin/rust/permissions'),
|
||||||
|
catalogue: () => req('/admin/rust/permissions/catalogue'),
|
||||||
|
|
||||||
|
saveGroup: (name, body) =>
|
||||||
|
req(`/admin/rust/permissions/groups/${encodeURIComponent(name)}`, { method: 'PUT', body }),
|
||||||
|
deleteGroup: (name) =>
|
||||||
|
req(`/admin/rust/permissions/groups/${encodeURIComponent(name)}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
addMember: (name, username) =>
|
||||||
|
req(`/admin/rust/permissions/groups/${encodeURIComponent(name)}/members`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { username },
|
||||||
|
}),
|
||||||
|
removeMember: (name, userId) =>
|
||||||
|
req(
|
||||||
|
`/admin/rust/permissions/groups/${encodeURIComponent(name)}/members/${encodeURIComponent(userId)}`,
|
||||||
|
{ method: 'DELETE' },
|
||||||
|
),
|
||||||
|
|
||||||
|
grant: (body) => req('/admin/rust/permissions/grants', { method: 'POST', body }),
|
||||||
|
revoke: (id) =>
|
||||||
|
req(`/admin/rust/permissions/grants/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
adoptDrift: (id) =>
|
||||||
|
req(`/admin/rust/permissions/drift/${encodeURIComponent(id)}/adopt`, { method: 'POST' }),
|
||||||
|
revokeDrift: (id) =>
|
||||||
|
req(`/admin/rust/permissions/drift/${encodeURIComponent(id)}/revoke`, { method: 'POST' }),
|
||||||
|
|
||||||
|
sync: (serverId = null) =>
|
||||||
|
req('/admin/rust/permissions/sync', { method: 'POST', body: serverId ? { serverId } : {} }),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the admin.users.detail extension slot ─────────────────────────────────
|
||||||
|
//
|
||||||
|
// The client half of R13's first slot. Core hands the component a `userId` and
|
||||||
|
// NOTHING else — not a client — so an extension builds its own bindings for the
|
||||||
|
// routes it registered at the other end (§3.5). These two are the only calls in
|
||||||
|
// this file whose path is core's rather than this module's: the resource is
|
||||||
|
// core's user, and the module's own segment is the part after it.
|
||||||
|
export const adminUserLinks = {
|
||||||
|
list: (userId) => req(`/admin/users/${encodeURIComponent(userId)}/rust/links`),
|
||||||
|
remove: (userId, steamId) =>
|
||||||
|
req(`/admin/users/${encodeURIComponent(userId)}/rust/links/${encodeURIComponent(steamId)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same panel's phase 7 half: what this person may do in game. The id in the
|
||||||
|
// path is the one the slot handed the component, so these send `userId` rather
|
||||||
|
// than a name — the screen already knows who it is looking at.
|
||||||
|
export const adminUserPermissions = {
|
||||||
|
list: (userId) => req(`/admin/users/${encodeURIComponent(userId)}/rust/permissions`),
|
||||||
|
grant: (userId, body) =>
|
||||||
|
req(`/admin/users/${encodeURIComponent(userId)}/rust/permissions/grants`, {
|
||||||
|
method: 'POST',
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
revoke: (userId, grantId) =>
|
||||||
|
req(
|
||||||
|
`/admin/users/${encodeURIComponent(userId)}/rust/permissions/grants/${encodeURIComponent(grantId)}`,
|
||||||
|
{ method: 'DELETE' },
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exported for the rare caller that needs the base itself — an `<img src>`, a
|
||||||
|
// download link, an EventSource. Reach for `request` first.
|
||||||
|
export { BASE, query }
|
||||||
|
|
||||||
|
export default {
|
||||||
|
servers,
|
||||||
|
playerServers,
|
||||||
|
playerLinks,
|
||||||
|
admin,
|
||||||
|
adminPermissions,
|
||||||
|
adminUserLinks,
|
||||||
|
adminUserPermissions,
|
||||||
|
BASE,
|
||||||
|
}
|
||||||
141
client/src/components/Feed.jsx
Normal file
141
client/src/components/Feed.jsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
// ── The feed: what happened on one server ─────────────────────────────────
|
||||||
|
//
|
||||||
|
// Rows come from `/public/rust/servers/:id/events`, which serves a default-deny
|
||||||
|
// ALLOWLIST (`server/catalogue.js`). Everything carrying an IP address, a
|
||||||
|
// player's report about another player, or the grid square somebody's base is in
|
||||||
|
// is stored and never answered here — so this component cannot leak one by
|
||||||
|
// forgetting to filter, which is the point of the boundary living on the server.
|
||||||
|
//
|
||||||
|
// It polls (org lead, phase 4): every twenty seconds while the tab is visible,
|
||||||
|
// paused when it is not. `usePolled` keeps the rows on screen across a refresh —
|
||||||
|
// see the comment at the top of that file for why core's `useAsync` cannot do
|
||||||
|
// this job.
|
||||||
|
|
||||||
|
import { EmptyState, ErrorState, Loading } from '../core.js'
|
||||||
|
import { describe, FILTERS, kindsFor } from '../lib/feed.js'
|
||||||
|
import { ago, clock } from '../lib/format.js'
|
||||||
|
import usePolled from '../hooks/usePolled.js'
|
||||||
|
import api from '../api.js'
|
||||||
|
|
||||||
|
const TONE = {
|
||||||
|
kill: 'var(--accent-bright)',
|
||||||
|
death: 'var(--muted)',
|
||||||
|
join: 'var(--mode-live, #5fb98a)',
|
||||||
|
leave: 'var(--dim)',
|
||||||
|
chat: 'var(--text)',
|
||||||
|
server: 'var(--mode-maint, #e6c26a)',
|
||||||
|
other: 'var(--muted)',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Feed({ serverId, wipeId, filter, onFilter }) {
|
||||||
|
const kinds = kindsFor(filter)
|
||||||
|
|
||||||
|
const { data, error, loading, at } = usePolled(
|
||||||
|
() => api.servers.events(serverId, { kinds, wipe: wipeId, limit: 100 }),
|
||||||
|
// The key is the QUESTION. Changing server, wipe or filter blanks the rows,
|
||||||
|
// because what is on screen is an answer to a different one; a poll tick
|
||||||
|
// does not, because it is the same question asked again.
|
||||||
|
{ key: `${serverId}|${wipeId || ''}|${filter}`, intervalMs: 20_000 },
|
||||||
|
)
|
||||||
|
|
||||||
|
const events = data ? data.events : []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className="sans"
|
||||||
|
style={{ display: 'flex', flexWrap: 'wrap', gap: 10, alignItems: 'center', marginBottom: 16 }}
|
||||||
|
>
|
||||||
|
<label style={{ color: 'var(--dim)', fontSize: '0.78rem' }}>
|
||||||
|
Showing{' '}
|
||||||
|
<select
|
||||||
|
value={filter}
|
||||||
|
onChange={(e) => onFilter(e.target.value)}
|
||||||
|
style={selectStyle}
|
||||||
|
>
|
||||||
|
{FILTERS.map((f) => (
|
||||||
|
<option key={f.id} value={f.id}>{f.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* What a refresh is FOR: saying when the page last managed one. Without
|
||||||
|
it a feed that stopped updating looks exactly like a quiet server. */}
|
||||||
|
{at && (
|
||||||
|
<span style={{ color: 'var(--dim)', fontSize: '0.74rem' }}>updated {ago(at)}</span>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<span style={{ color: 'var(--mode-maint, #e6c26a)', fontSize: '0.74rem' }}>
|
||||||
|
the last refresh failed — showing what we had
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <Loading />}
|
||||||
|
|
||||||
|
{/* An error with nothing to fall back on is the only case that takes over
|
||||||
|
the panel. A failed REFRESH keeps the rows and says so in the line
|
||||||
|
above, because a site whose premise is "it renders while the game is
|
||||||
|
off" must not blank itself the first time a request does. */}
|
||||||
|
{error && !data && <ErrorState error={error} />}
|
||||||
|
|
||||||
|
{data && events.length === 0 && (
|
||||||
|
<EmptyState
|
||||||
|
title="Nothing here yet"
|
||||||
|
message="Nothing this server has reported matches. A server that has just been added has no history until it says something."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{events.length > 0 && (
|
||||||
|
<ol style={{ listStyle: 'none', margin: 0, padding: 0 }}>
|
||||||
|
{events.map((event) => {
|
||||||
|
const line = describe(event)
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={event.id}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 12,
|
||||||
|
alignItems: 'baseline',
|
||||||
|
padding: '7px 0',
|
||||||
|
borderBottom: '1px solid var(--line-soft, var(--line))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<time
|
||||||
|
className="sans"
|
||||||
|
dateTime={new Date(event.t).toISOString()}
|
||||||
|
title={new Date(event.t).toLocaleString()}
|
||||||
|
style={{ flex: 'none', color: 'var(--dim)', fontSize: '0.74rem', minWidth: '5.6rem' }}
|
||||||
|
>
|
||||||
|
{clock(event.t)}
|
||||||
|
</time>
|
||||||
|
<span style={{ color: TONE[line.tone] || 'var(--muted)', fontSize: '0.92rem' }}>
|
||||||
|
{line.actor && <strong style={{ color: 'var(--ink)' }}>{line.actor}</strong>}
|
||||||
|
{line.actor && (line.join || ' ')}
|
||||||
|
{line.verb}
|
||||||
|
{line.subject && ' '}
|
||||||
|
{line.subject && <strong style={{ color: 'var(--ink)' }}>{line.subject}</strong>}
|
||||||
|
{line.detail && (
|
||||||
|
<span className="sans" style={{ color: 'var(--dim)', fontSize: '0.76rem' }}>
|
||||||
|
{' · '}
|
||||||
|
{line.detail}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectStyle = {
|
||||||
|
background: 'var(--panel-flat, transparent)',
|
||||||
|
color: 'var(--text)',
|
||||||
|
border: '1px solid var(--line)',
|
||||||
|
borderRadius: 'var(--radius-input, 6px)',
|
||||||
|
padding: '3px 8px',
|
||||||
|
fontSize: '0.78rem',
|
||||||
|
}
|
||||||
73
client/src/components/FooterStatus.jsx
Normal file
73
client/src/components/FooterStatus.jsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
// ── This module's fill for core's `site.footer.status` slot ───────────────
|
||||||
|
//
|
||||||
|
// R13, and the contract is MODULE_API.md §3.7. Core owns the position in the
|
||||||
|
// footer's info row and the separator around it, and passes `linkStyle` so the
|
||||||
|
// row stays visually one row. **The label, the destination, the data and whether
|
||||||
|
// anything renders at all are this component's** — that is the whole division,
|
||||||
|
// and it is why the slot is named for a place rather than for a meaning.
|
||||||
|
//
|
||||||
|
// ── The live count, and what it costs ─────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The org lead chose a live count ("3 servers · 42 online") over a static link,
|
||||||
|
// so this fetches. Be clear-eyed about where it fetches from: core renders
|
||||||
|
// `SiteFooter` inside `PublicLayout`, and every public page renders
|
||||||
|
// `PublicLayout` ITSELF (§3.3) — so this component mounts once per public page
|
||||||
|
// view, not once per session. Every public page on the site therefore carries one
|
||||||
|
// `/public/rust/servers` request, including pages that have nothing to do with
|
||||||
|
// Rust.
|
||||||
|
//
|
||||||
|
// Two things keep that honest rather than merely cheap:
|
||||||
|
//
|
||||||
|
// • **It renders NOTHING until it has an answer, and nothing again if the
|
||||||
|
// request fails.** An unfilled slot renders nothing and core's `wrap` takes
|
||||||
|
// the separator with it, so a failed fetch degrades to exactly the footer an
|
||||||
|
// instance with no module installed has. A spinner in a footer would be worse
|
||||||
|
// than silence on every page of the site.
|
||||||
|
// • **It never polls.** One request per page view is a cost; a timer in the
|
||||||
|
// footer of every page would be a different kind of thing entirely.
|
||||||
|
//
|
||||||
|
// If that per-page request ever shows up in an operator's logs as a problem, the
|
||||||
|
// fix is a short-lived module-scope cache here — the decision to keep the number
|
||||||
|
// live stays intact, and nothing else on the site has to change.
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import api from '../api.js'
|
||||||
|
|
||||||
|
export default function FooterStatus({ linkStyle }) {
|
||||||
|
const [summary, setSummary] = useState(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let live = true
|
||||||
|
|
||||||
|
api.servers
|
||||||
|
.list()
|
||||||
|
.then(({ servers }) => {
|
||||||
|
if (!live) return
|
||||||
|
// `online` already accounts for staleness — the model refuses to let a
|
||||||
|
// row that has not been written in five minutes claim a server is up —
|
||||||
|
// so this is a sum, not a judgement.
|
||||||
|
setSummary({
|
||||||
|
servers: servers.length,
|
||||||
|
players: servers.reduce((total, server) => total + (server.online ? server.players : 0), 0),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
// Silence, deliberately. This is the footer of every page on the site; a
|
||||||
|
// module that cannot reach its own API has nothing to say there.
|
||||||
|
.catch(() => {})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
live = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (!summary || summary.servers === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link to="/rust" style={linkStyle}>
|
||||||
|
{summary.servers === 1 ? '1 server' : `${summary.servers} servers`}
|
||||||
|
{' · '}
|
||||||
|
{summary.players === 1 ? '1 online' : `${summary.players} online`}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
110
client/src/components/Leaderboard.jsx
Normal file
110
client/src/components/Leaderboard.jsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
// ── The leaderboard ───────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Per wipe when a wipe is selected, all-time when it is not (R12). The two are
|
||||||
|
// the same rows summed differently rather than two sets of counters, so they can
|
||||||
|
// never disagree — which is worth knowing here because it means "All time" is
|
||||||
|
// not a slower or less accurate answer, it is the same table without a WHERE.
|
||||||
|
//
|
||||||
|
// It does NOT poll. A leaderboard moves on the scale of a session; a table that
|
||||||
|
// re-sorted itself under the reader's cursor every twenty seconds would be worse
|
||||||
|
// than one that is four minutes old, and the page has a `Refresh` on the tab
|
||||||
|
// strip for anybody who disagrees.
|
||||||
|
|
||||||
|
import { EmptyState, ErrorState, Loading, useAsync } from '../core.js'
|
||||||
|
import { ago, count, duration, shortId } from '../lib/format.js'
|
||||||
|
import api from '../api.js'
|
||||||
|
|
||||||
|
// `sort` is the API's own vocabulary (`kills`, `deaths`, `npcKills`, `playtime`),
|
||||||
|
// and the column it maps to is this file's. Keeping them in one list is what
|
||||||
|
// stops a header that sorts by something other than what it says.
|
||||||
|
const COLUMNS = [
|
||||||
|
{ key: 'kills', label: 'Kills', sort: 'kills', value: (r) => count(r.kills) },
|
||||||
|
{ key: 'deaths', label: 'Deaths', sort: 'deaths', value: (r) => count(r.deaths) },
|
||||||
|
{ key: 'npcKills', label: 'NPC kills', sort: 'npcKills', value: (r) => count(r.npcKills) },
|
||||||
|
{ key: 'structures', label: 'Structures', sort: null, value: (r) => count(r.structures) },
|
||||||
|
{ key: 'playtimeSec', label: 'Played', sort: 'playtime', value: (r) => duration(r.playtimeSec) },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function Leaderboard({ serverId, wipeId, sort, onSort }) {
|
||||||
|
const { data, loading, error } = useAsync(
|
||||||
|
() => api.servers.leaderboard(serverId, { wipe: wipeId, sort, limit: 50 }),
|
||||||
|
[serverId, wipeId, sort],
|
||||||
|
)
|
||||||
|
|
||||||
|
const rows = data ? data.leaderboard : []
|
||||||
|
|
||||||
|
if (loading) return <Loading />
|
||||||
|
if (error) return <ErrorState error={error} />
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
title="No scores yet"
|
||||||
|
message={
|
||||||
|
wipeId
|
||||||
|
? 'Nobody has done anything countable on this wipe yet.'
|
||||||
|
: 'This server has not reported anything countable yet.'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ overflowX: 'auto' }}>
|
||||||
|
<table className="sans" style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.86rem' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ textAlign: 'left', color: 'var(--dim)', fontSize: '0.72rem', letterSpacing: '0.08em' }}>
|
||||||
|
<th style={{ ...cell, textTransform: 'uppercase' }}>Player</th>
|
||||||
|
{COLUMNS.map((column) => (
|
||||||
|
<th key={column.key} style={{ ...cell, textAlign: 'right', textTransform: 'uppercase' }}>
|
||||||
|
{column.sort ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSort(column.sort)}
|
||||||
|
aria-label={`Sort by ${column.label}`}
|
||||||
|
style={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
padding: 0,
|
||||||
|
font: 'inherit',
|
||||||
|
letterSpacing: 'inherit',
|
||||||
|
textTransform: 'inherit',
|
||||||
|
color: column.sort === sort ? 'var(--accent-bright)' : 'var(--dim)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{column.label}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
column.label
|
||||||
|
)}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
<th style={{ ...cell, textAlign: 'right', textTransform: 'uppercase' }}>Last seen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row, index) => (
|
||||||
|
<tr key={row.steamId} style={{ borderTop: '1px solid var(--line-soft, var(--line))' }}>
|
||||||
|
<td style={cell}>
|
||||||
|
<span style={{ color: 'var(--dim)', marginRight: 8 }}>{index + 1}</span>
|
||||||
|
{/* A player this module has never seen NAMED is shown by the tail
|
||||||
|
of their id rather than as a blank: the row is real, and a
|
||||||
|
nameless one reads as a rendering fault. */}
|
||||||
|
<strong style={{ color: 'var(--ink)' }}>{row.name || shortId(row.steamId)}</strong>
|
||||||
|
</td>
|
||||||
|
{COLUMNS.map((column) => (
|
||||||
|
<td key={column.key} style={{ ...cell, textAlign: 'right' }}>
|
||||||
|
{column.value(row)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
<td style={{ ...cell, textAlign: 'right', color: 'var(--dim)' }}>{ago(row.lastSeen)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cell = { padding: '8px 10px', whiteSpace: 'nowrap' }
|
||||||
94
client/src/components/Online.jsx
Normal file
94
client/src/components/Online.jsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
// ── Who is on the server right now ────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Read from the presence BOARD, not counted from connect and disconnect events:
|
||||||
|
// the bridge re-sends the whole board on every connect and every sixty seconds,
|
||||||
|
// so this is right even after the website has missed something (PROTOCOL.md
|
||||||
|
// §8.3). Counting transitions instead would drift, and drift in the direction
|
||||||
|
// people notice — players who never left.
|
||||||
|
//
|
||||||
|
// It polls with the feed, because "who is on" is the one thing on this page that
|
||||||
|
// is a live question.
|
||||||
|
|
||||||
|
import { EmptyState, ErrorState, Loading } from '../core.js'
|
||||||
|
import { duration, shortId } from '../lib/format.js'
|
||||||
|
import usePolled from '../hooks/usePolled.js'
|
||||||
|
import api from '../api.js'
|
||||||
|
|
||||||
|
export default function Online({ serverId, online }) {
|
||||||
|
const { data, error, loading } = usePolled(() => api.servers.online(serverId), {
|
||||||
|
key: serverId,
|
||||||
|
intervalMs: 20_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const players = data ? data.players : []
|
||||||
|
|
||||||
|
if (loading) return <Loading />
|
||||||
|
if (error && !data) return <ErrorState error={error} />
|
||||||
|
|
||||||
|
if (players.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
title={online ? 'Nobody is on' : 'The server is offline'}
|
||||||
|
message={
|
||||||
|
online
|
||||||
|
? 'The server is up and the island is empty. Somebody has to be first.'
|
||||||
|
: 'Presence is the one thing on this page that cannot be answered from the record — it is who is connected now, and nothing is.'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* A board is the last one that ARRIVED, and an unreachable sidecar does not
|
||||||
|
clear it — deliberately, because the rows are still the best answer
|
||||||
|
anybody has. But presented bare they read as "these people are on right
|
||||||
|
now", which is the one thing an offline server cannot be saying. The
|
||||||
|
page walk found this with a fixture server whose header said Offline
|
||||||
|
above three apparently-connected players. */}
|
||||||
|
{!online && (
|
||||||
|
<p className="sans" style={{ color: 'var(--dim)', fontSize: '0.8rem', marginTop: 0 }}>
|
||||||
|
This server is offline. Below is the last board it sent, not who is on it now.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
|
||||||
|
{players.map((player) => (
|
||||||
|
<li
|
||||||
|
key={player.steamId}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'baseline',
|
||||||
|
gap: 12,
|
||||||
|
padding: '8px 0',
|
||||||
|
borderBottom: '1px solid var(--line-soft, var(--line))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<strong style={{ color: 'var(--ink)' }}>{player.name || shortId(player.steamId)}</strong>
|
||||||
|
{/* Sleeping is not idle and not offline — a sleeping player's body is
|
||||||
|
in the world and can be killed, which is why the board carries the
|
||||||
|
flag at all. */}
|
||||||
|
{player.sleeping && (
|
||||||
|
<span className="sans" style={{ color: 'var(--dim)', fontSize: '0.76rem' }}> · sleeping</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{/* `connectedAt` is absent for a player who was already on when the
|
||||||
|
plugin loaded — an unknown session length, which is not a session of
|
||||||
|
no length. Saying nothing is the honest render of that. */}
|
||||||
|
<span className="sans" style={{ color: 'var(--dim)', fontSize: '0.78rem', whiteSpace: 'nowrap' }}>
|
||||||
|
{player.connectedAt ? `on for ${sessionSoFar(player.connectedAt)}` : ''}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How long a player has been on, from the DATETIME the board reported. */
|
||||||
|
function sessionSoFar(connectedAt) {
|
||||||
|
const since = Date.parse(connectedAt)
|
||||||
|
if (Number.isNaN(since)) return ''
|
||||||
|
return duration((Date.now() - since) / 1000)
|
||||||
|
}
|
||||||
63
client/src/components/Tabs.jsx
Normal file
63
client/src/components/Tabs.jsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
// ── Tabs, bundled rather than borrowed ────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The shared kit is nine members and it is CLOSED (MODULE_API.md §3.4): layout,
|
||||||
|
// headings, the three data-page states, the fetch hook, the session, the site
|
||||||
|
// and `Slot`. A tab strip is not in it, so it is here — which is the kit working
|
||||||
|
// as designed rather than a gap in it. What the kit guarantees is that a module
|
||||||
|
// page looks like the site while it loads and while it fails; everything a page
|
||||||
|
// builds on top of that is the module's own.
|
||||||
|
//
|
||||||
|
// It is styled with core's CSS VARIABLES and its `.pill` class rather than with
|
||||||
|
// colours of its own, so it re-themes with the instance (THEMING_AND_NAV.md).
|
||||||
|
// The one class this module must never write by hand is the shell wrapper —
|
||||||
|
// `PublicLayout`'s `shell` prop exists precisely so that one stays core's.
|
||||||
|
//
|
||||||
|
// **The selected tab lives in the URL, not in this component.** A tab strip that
|
||||||
|
// owned its own state would make every panel on this page unlinkable: "look at
|
||||||
|
// the leaderboard for this server" would be a sentence rather than a link, back
|
||||||
|
// would leave the page entirely, and a refresh would land on the first tab. So
|
||||||
|
// this is a controlled component and `ServerDetail` keeps the state in a search
|
||||||
|
// parameter.
|
||||||
|
|
||||||
|
export default function Tabs({ tabs, active, onSelect, label = 'Sections' }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="tablist"
|
||||||
|
aria-label={label}
|
||||||
|
className="sans"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 8,
|
||||||
|
borderBottom: '1px solid var(--line)',
|
||||||
|
paddingBottom: 12,
|
||||||
|
marginBottom: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const selected = tab.id === active
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={selected}
|
||||||
|
onClick={() => onSelect(tab.id)}
|
||||||
|
style={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '6px 14px',
|
||||||
|
borderRadius: 'var(--radius-pill, 999px)',
|
||||||
|
fontSize: '0.82rem',
|
||||||
|
letterSpacing: '0.04em',
|
||||||
|
border: `1px solid ${selected ? 'var(--accent)' : 'var(--line)'}`,
|
||||||
|
background: selected ? 'var(--blue)' : 'transparent',
|
||||||
|
color: selected ? 'var(--accent-bright)' : 'var(--muted)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
54
client/src/components/WipeSelect.jsx
Normal file
54
client/src/components/WipeSelect.jsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
// ── "This wipe" or "All time" ─────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// One control, used by two panels, because the wipe is a property of the PAGE
|
||||||
|
// rather than of the feed or the leaderboard — a reader who has chosen last
|
||||||
|
// month's map means it for both, and two selects that could disagree is a page
|
||||||
|
// that shows one wipe's kills next to another's leaderboard.
|
||||||
|
//
|
||||||
|
// It loads the wipe list itself. That is a second request for the same list the
|
||||||
|
// Wipes tab fetches, and it is the right trade: the alternative is the page
|
||||||
|
// fetching it on mount for a control most visitors never touch, on every visit,
|
||||||
|
// for every server.
|
||||||
|
|
||||||
|
import { useAsync } from '../core.js'
|
||||||
|
import { day } from '../lib/format.js'
|
||||||
|
import api from '../api.js'
|
||||||
|
|
||||||
|
/** The value that means "no wipe filter at all". Never the empty string — see `api.js`'s `query`. */
|
||||||
|
export const ALL_TIME = 'all'
|
||||||
|
|
||||||
|
export default function WipeSelect({ serverId, value, onChange, currentWipeId }) {
|
||||||
|
const { data } = useAsync(() => api.servers.wipes(serverId), [serverId])
|
||||||
|
const wipes = data ? data.wipes : []
|
||||||
|
|
||||||
|
// A server with one wipe has nothing to choose between, so the control is not
|
||||||
|
// offered. "All time" and "this wipe" are the same answer there, and a select
|
||||||
|
// with one real option is furniture that invites a question with no answer.
|
||||||
|
if (wipes.length < 2) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label className="sans" style={{ color: 'var(--dim)', fontSize: '0.78rem' }}>
|
||||||
|
Wipe{' '}
|
||||||
|
<select
|
||||||
|
value={value || ALL_TIME}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
style={{
|
||||||
|
background: 'var(--panel-flat, transparent)',
|
||||||
|
color: 'var(--text)',
|
||||||
|
border: '1px solid var(--line)',
|
||||||
|
borderRadius: 'var(--radius-input, 6px)',
|
||||||
|
padding: '3px 8px',
|
||||||
|
fontSize: '0.78rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value={ALL_TIME}>All time</option>
|
||||||
|
{wipes.map((wipe) => (
|
||||||
|
<option key={wipe.wipeId} value={wipe.wipeId}>
|
||||||
|
{day(wipe.saveCreatedAt || wipe.firstSeen)}
|
||||||
|
{wipe.wipeId === currentWipeId ? ' (current)' : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
85
client/src/components/Wipes.jsx
Normal file
85
client/src/components/Wipes.jsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
// ── Every wipe this server has had ────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The list is what makes the rest of the page navigable — picking a wipe here
|
||||||
|
// filters the feed and the leaderboard — and it is also the proof R12 asks for:
|
||||||
|
// a wipe that ended is still here, with its record still attached. A Rust server
|
||||||
|
// wipes monthly, and a community site that forgot the previous map every time
|
||||||
|
// would throw away most of what it knows about its own players.
|
||||||
|
//
|
||||||
|
// `wipeId` is derived by the bridge PLUGIN from the save's creation time and
|
||||||
|
// stamped on every frame (PROTOCOL.md §8.2), so the id in this list is the same
|
||||||
|
// id the events and the leaderboard filter by. There is no second derivation
|
||||||
|
// anywhere that could disagree.
|
||||||
|
|
||||||
|
import { EmptyState, ErrorState, Loading, useAsync } from '../core.js'
|
||||||
|
import { ago, day } from '../lib/format.js'
|
||||||
|
import api from '../api.js'
|
||||||
|
|
||||||
|
export default function Wipes({ serverId, currentWipeId, selected, onSelect }) {
|
||||||
|
const { data, loading, error } = useAsync(() => api.servers.wipes(serverId), [serverId])
|
||||||
|
const wipes = data ? data.wipes : []
|
||||||
|
|
||||||
|
if (loading) return <Loading />
|
||||||
|
if (error) return <ErrorState error={error} />
|
||||||
|
|
||||||
|
if (wipes.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
title="No wipes recorded"
|
||||||
|
message="A wipe appears here once this server has reported something during it."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
|
||||||
|
{wipes.map((wipe) => {
|
||||||
|
const current = wipe.wipeId === currentWipeId
|
||||||
|
const active = wipe.wipeId === selected
|
||||||
|
return (
|
||||||
|
<li key={wipe.wipeId} style={{ borderBottom: '1px solid var(--line-soft, var(--line))' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(wipe.wipeId)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
width: '100%',
|
||||||
|
gap: 12,
|
||||||
|
alignItems: 'baseline',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '10px 6px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: active ? 'var(--blue)' : 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
color: 'inherit',
|
||||||
|
font: 'inherit',
|
||||||
|
textAlign: 'left',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<strong style={{ color: 'var(--ink)' }}>
|
||||||
|
{/* The save's creation time is the wipe's own date; `firstSeen`
|
||||||
|
is when THIS website first heard about it, and they differ
|
||||||
|
by however long the module was not installed. The first is
|
||||||
|
the wipe, so it leads. */}
|
||||||
|
{day(wipe.saveCreatedAt || wipe.firstSeen)}
|
||||||
|
</strong>
|
||||||
|
{current && (
|
||||||
|
<span className="sans" style={{ color: 'var(--mode-live, #5fb98a)', fontSize: '0.74rem' }}>
|
||||||
|
{' · current'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="sans" style={{ display: 'block', color: 'var(--dim)', fontSize: '0.74rem' }}>
|
||||||
|
{wipe.wipeId}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="sans" style={{ color: 'var(--dim)', fontSize: '0.78rem', whiteSpace: 'nowrap' }}>
|
||||||
|
last heard {ago(wipe.lastSeen)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
85
client/src/core.js
Normal file
85
client/src/core.js
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
// ── What core hands this module, on the client side ────────────────────────
|
||||||
|
//
|
||||||
|
// The client twin of `server/core.js`, and deliberately much simpler than it.
|
||||||
|
// Every page imports its layout, its state components and its hooks from here,
|
||||||
|
// so the boundary is one file. The normative contract is MODULE_API.md §3.2 and
|
||||||
|
// §3.4.
|
||||||
|
//
|
||||||
|
// **Why this is a plain read and the server's is a lazy accessor.** On the
|
||||||
|
// server, `ctx` arrives at `register(ctx)` — after every `require` has already
|
||||||
|
// run — so `server/core.js` has to defer resolution to call time or a router
|
||||||
|
// would capture `undefined` at file scope. There is no such gap here.
|
||||||
|
// `window.__rg` is published by core's own bundle (client/src/modules/shared.js),
|
||||||
|
// and every module chunk is a deferred script the server injects *after* that
|
||||||
|
// bundle's tag, so by the time the first line of this file executes the global
|
||||||
|
// is already there. Reading it once, at module scope, is safe — and it means a
|
||||||
|
// component keeps the ordinary `import { PageHeader } from '…'` shape rather
|
||||||
|
// than being wrapped in an accessor that would cost it its identity.
|
||||||
|
//
|
||||||
|
// The absent-global case is handled by `shim/rg.js`, which every shim beside it
|
||||||
|
// also goes through — the shims touch the global before this file does, so a
|
||||||
|
// check here would be unreachable.
|
||||||
|
|
||||||
|
import { createElement } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { rg as shared } from './shim/rg.js'
|
||||||
|
|
||||||
|
const rg = shared()
|
||||||
|
|
||||||
|
// ── The shared-dependency self-check ───────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Keep this. There are two BUILD guards on the same rule — `assertSharedNotBundled`
|
||||||
|
// in vite.config.js at resolution time, and `scripts/checkExternals.js` on the
|
||||||
|
// finished artifact — and both reason about the chunk in isolation. Neither can
|
||||||
|
// see the one failure that only exists once the chunk meets a core: a
|
||||||
|
// `window.__rg` whose React is not the React that rendered the page.
|
||||||
|
//
|
||||||
|
// Identity is the only question worth asking. A second React satisfies every
|
||||||
|
// type check, renders its first element happily, and then throws about an invalid
|
||||||
|
// hook call somewhere unrelated — in a component that has nothing to do with it.
|
||||||
|
if (createElement !== rg.react.createElement || createRoot !== rg.reactDom.createRoot || Link !== rg.router.Link) {
|
||||||
|
console.error(
|
||||||
|
'[rust] the bindings this chunk imported are not the ones core published — it has bundled ' +
|
||||||
|
'its own copy of a shared dependency. Check the aliases in vite.config.js (MODULE_API.md §3.6).',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The curated kit (§3.4). Nine exports, and it is CLOSED: layout, headings, the
|
||||||
|
// three data-page states, the fetch hook, read-only access to the session and the
|
||||||
|
// site's settings, and `Slot`. Anything else your pages need — tables, tabs, an
|
||||||
|
// editor — you bundle yourself, in a `components/` directory of your own.
|
||||||
|
//
|
||||||
|
// `Slot` is the one that is not a widget. It renders a place THIS module declared
|
||||||
|
// for core to fill (`entry.jsx`, and `routes/public/Clan.jsx` where two are used):
|
||||||
|
// the inverted direction of the extension-slot mechanism, added in 1.6.0. It is in
|
||||||
|
// the shared kit rather than reimplementable for the reason the whole kit exists —
|
||||||
|
// a second error boundary with different behaviour would be a second bug, and what
|
||||||
|
// this one contains is CORE's content failing inside YOUR page.
|
||||||
|
//
|
||||||
|
// Closed is a real constraint and it is the price of the boundary being worth
|
||||||
|
// anything: adding a member is a minor `MODULE_API_VERSION` bump, and changing an
|
||||||
|
// existing prop on a kit component is a major one. Use them, though. A module page that
|
||||||
|
// ships its own layout is a page that stops looking like the site it is installed
|
||||||
|
// in, and drifts further every time core changes.
|
||||||
|
export const {
|
||||||
|
PublicLayout,
|
||||||
|
PageHeader,
|
||||||
|
Loading,
|
||||||
|
ErrorState,
|
||||||
|
EmptyState,
|
||||||
|
useAsync,
|
||||||
|
useAuth,
|
||||||
|
useSite,
|
||||||
|
Slot,
|
||||||
|
} = rg.ui
|
||||||
|
|
||||||
|
// The registry, for entry.jsx. Everything else here is read by pages.
|
||||||
|
export const registry = rg.registry
|
||||||
|
|
||||||
|
// The core API version this module was loaded against. Logged by entry.jsx —
|
||||||
|
// `module.json`'s `coreApi` range is checked by the loader before this file is
|
||||||
|
// ever served, so there is nothing to re-check, only something to report.
|
||||||
|
export const coreApiVersion = rg.version
|
||||||
|
|
||||||
|
export default rg
|
||||||
162
client/src/entry.jsx
Normal file
162
client/src/entry.jsx
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
// ── The client entry point ────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Core serves `dist/entry.js` from this module's directory and injects it into
|
||||||
|
// its own HTML as a same-origin `<script type="module" src>` before `</body>`.
|
||||||
|
// This file registers what the module has; core renders it. Normative:
|
||||||
|
// MODULE_API.md §3.3.
|
||||||
|
//
|
||||||
|
// **Registration is synchronous and happens at evaluation time.** Module scripts
|
||||||
|
// are deferred, so this runs after core's bundle — which is where `window.__rg`
|
||||||
|
// is published — and before core's first render. There is no subscription and no
|
||||||
|
// late registration: a module that registered asynchronously would register after
|
||||||
|
// the route table had been read, and the symptom is a page that redirects home
|
||||||
|
// with nothing logged anywhere.
|
||||||
|
//
|
||||||
|
// So everything below is a plain top-level call and every page is a STATIC
|
||||||
|
// import. Lazy-loading the routes is the natural instinct for a chunk that grows,
|
||||||
|
// and it is the one thing this seam cannot have.
|
||||||
|
|
||||||
|
import { registry, coreApiVersion } from './core.js'
|
||||||
|
|
||||||
|
import Servers from './routes/public/Servers.jsx'
|
||||||
|
import ServerDetail from './routes/public/ServerDetail.jsx'
|
||||||
|
import Account from './routes/player/Account.jsx'
|
||||||
|
import Permissions from './routes/admin/Permissions.jsx'
|
||||||
|
import UserRustSections from './routes/admin/UserRustSections.jsx'
|
||||||
|
import FooterStatus from './components/FooterStatus.jsx'
|
||||||
|
import { IconKey, IconLink } from './icons.jsx'
|
||||||
|
|
||||||
|
// The module id, exactly as `module.json` spells it. Core keys the registry by it
|
||||||
|
// and prefixes every route path with it.
|
||||||
|
const ID = 'rust'
|
||||||
|
|
||||||
|
// ── Routes ────────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Paths are relative to the module's namespace and core prefixes them. Whatever
|
||||||
|
// is written here, a public route lands at `/<id>/<path>`, an admin route at
|
||||||
|
// `/admin/<id>/<path>` and a player route at `/player/<id>/<path>`. A module
|
||||||
|
// cannot write the segment its routes hang under, which is the point: two modules
|
||||||
|
// installed side by side cannot collide, and an operator can see from a URL which
|
||||||
|
// module served it.
|
||||||
|
//
|
||||||
|
// So the list below is at `/rust` and the detail page at `/rust/servers/:id`.
|
||||||
|
//
|
||||||
|
// **Note what is NOT here: an auth wrapper.** `gate: { roles: [...] }` is
|
||||||
|
// available and core applies it as its own `RoleGate`; supplying your own is not
|
||||||
|
// possible, because the sidebar and the route table have to agree about who may
|
||||||
|
// see what, and they only do if one thing decides.
|
||||||
|
//
|
||||||
|
// R8's landing page is the server list, and `/rust/servers/:id` hangs beneath it.
|
||||||
|
//
|
||||||
|
// **The list is registered with an EMPTY path**, which core renders as the
|
||||||
|
// module's namespace root: `/rust`. The prefixing code strips the separator it
|
||||||
|
// would otherwise leave behind (`registry.js`: `${id}/${path}` with trailing
|
||||||
|
// slashes trimmed), so a module can own its own root without being able to spell
|
||||||
|
// its way out of it. Phase 1 served this page at `/rust/servers` and left `/rust`
|
||||||
|
// to core's CMS catch-all; the org lead settled it at `/rust` in phase 4, so the
|
||||||
|
// address an operator links to is the module's name.
|
||||||
|
//
|
||||||
|
// React Router ranks a static segment above a dynamic one, so `/rust` wins
|
||||||
|
// against core's `/:slug` CMS route without depending on registration order.
|
||||||
|
//
|
||||||
|
// The player route is registered with an empty path for the same reason the
|
||||||
|
// public list is: `/player/rust` is the whole of what this module asks a player
|
||||||
|
// to do, and a landing page above one page is a page nobody wants. Core applies
|
||||||
|
// its own portal chrome and its own auth gate to the tier, so the component
|
||||||
|
// renders no layout and re-implements no check.
|
||||||
|
//
|
||||||
|
// **The admin route arrives in phase 7 and is this module's first.** Everything
|
||||||
|
// before it was configured through the API — the server rows still are — because
|
||||||
|
// nothing until now had to be AUTHORED. A permission model is different in kind:
|
||||||
|
// it is a thing an operator composes and keeps looking at, and there is no
|
||||||
|
// version of "grant somebody VIP" that belongs in a terminal.
|
||||||
|
//
|
||||||
|
// It is registered with an empty path, so it lands at `/admin/rust`, and core
|
||||||
|
// applies the admin tier's own gate. The routes underneath it are stricter than
|
||||||
|
// that gate (`requireRole('admin')` on every one), which is a server-side answer
|
||||||
|
// rather than a client one: a moderator who reached this page would see it fail
|
||||||
|
// honestly rather than be quietly shown a page that cannot save.
|
||||||
|
registry.registerRoutes(ID, {
|
||||||
|
public: [
|
||||||
|
{ path: '', element: <Servers /> },
|
||||||
|
{ path: 'servers/:id', element: <ServerDetail /> },
|
||||||
|
],
|
||||||
|
player: [{ path: '', element: <Account /> }],
|
||||||
|
admin: [{ path: '', element: <Permissions /> }],
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Nav ───────────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// A registered row is an ORDINARY row from here on. It interleaves into core's
|
||||||
|
// own navigation, and an operator can reorder it, relabel it or hide it from the
|
||||||
|
// admin nav editor exactly as they can core's — because the interleave happens
|
||||||
|
// before the override merge, and the override layer is keyed by `to`.
|
||||||
|
//
|
||||||
|
// Three fields worth knowing before you need them:
|
||||||
|
//
|
||||||
|
// • `order` places the row among core's, which are keyed by their index. A row
|
||||||
|
// with NO order appends after them, rather than defaulting to 0 — otherwise
|
||||||
|
// "I didn't ask for a position" would mean "put me first".
|
||||||
|
// • `group` (admin sidebar) names an existing core group; an unknown name
|
||||||
|
// appends a new group at the end rather than dropping the row.
|
||||||
|
// • `icon` is a component, and core supplies no fallback. Public header rows
|
||||||
|
// carry no icons, so there is none here — but an admin or player row without
|
||||||
|
// one is the only row in its sidebar with no glyph, which reads as breakage.
|
||||||
|
registry.registerNav(ID, {
|
||||||
|
area: 'public',
|
||||||
|
items: [{ label: 'Servers', to: '/rust' }],
|
||||||
|
})
|
||||||
|
|
||||||
|
// The player portal's row. It carries an `icon` because core draws one on every
|
||||||
|
// portal row — a row without one is the only text in a column of glyphs, and
|
||||||
|
// core used to render `<n.icon />` unguarded, which blanked the whole portal.
|
||||||
|
//
|
||||||
|
// No `order`: an unordered row appends after core's own rather than claiming a
|
||||||
|
// position it was not given. Account, appeals and notifications are what a player
|
||||||
|
// came to the portal for; linking a game account is what they do once.
|
||||||
|
registry.registerNav(ID, {
|
||||||
|
area: 'player',
|
||||||
|
items: [{ label: 'Rust', to: '/player/rust', icon: IconLink }],
|
||||||
|
})
|
||||||
|
|
||||||
|
// The admin sidebar's row. `group` names an existing core group — an unknown name
|
||||||
|
// appends a new group at the end rather than dropping the row, which is the
|
||||||
|
// failure mode to avoid here: a row nobody can find is a feature nobody has.
|
||||||
|
//
|
||||||
|
// It carries an icon for the same reason the player row does: core draws one on
|
||||||
|
// every sidebar row, and the one without is the only text in a column of glyphs.
|
||||||
|
registry.registerNav(ID, {
|
||||||
|
area: 'admin',
|
||||||
|
items: [{ label: 'Rust permissions', to: '/admin/rust', icon: IconKey }],
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Extension slots ───────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Core declares a slot, only core may declare one, and at most one module may
|
||||||
|
// fill it (§3.7). `site.footer.status` is the status-ish spot in core's footer
|
||||||
|
// info row: core owns the position and passes `linkStyle`; the label, the
|
||||||
|
// destination, the data and whether anything renders at all are the module's.
|
||||||
|
//
|
||||||
|
// It is a CLIENT slot and cannot be named in `module.json`'s `extensions` —
|
||||||
|
// that array is validated against the SERVER registry, and naming a client slot
|
||||||
|
// there fails the load outright with `unknown extension slot`. Phase 1 found
|
||||||
|
// that the hard way; the two halves of R13 are declared in different places on
|
||||||
|
// purpose.
|
||||||
|
registry.registerExtension(ID, 'site.footer.status', FooterStatus)
|
||||||
|
|
||||||
|
// R13's other slot, and the one that IS named in `module.json` — because it has
|
||||||
|
// a server half too (`server/router/admin/usersRust.router.js`). The two halves
|
||||||
|
// carry one name on purpose: a module that adds routes under
|
||||||
|
// `/api/v1/admin/users/:id` is the module with something to show on that page.
|
||||||
|
//
|
||||||
|
// Core passes `userId` and nothing else, so the component builds its own client
|
||||||
|
// for the routes the server half registered. It renders NOTHING for a user with
|
||||||
|
// no linked Steam account, which is most of them.
|
||||||
|
registry.registerExtension(ID, 'admin.users.detail', UserRustSections)
|
||||||
|
|
||||||
|
// `module.json`'s `coreApi` range was checked by the loader before this file was
|
||||||
|
// ever served, so there is nothing to re-check here. Log it anyway: a mismatch
|
||||||
|
// between the core that validated the manifest and the core that published this
|
||||||
|
// global is otherwise invisible from the browser, which is where the client half
|
||||||
|
// actually fails.
|
||||||
|
console.info(`[${ID}] registered against core API ${coreApiVersion}`)
|
||||||
116
client/src/hooks/usePolled.js
Normal file
116
client/src/hooks/usePolled.js
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
// ── A poll that keeps what it already had ─────────────────────────────────
|
||||||
|
//
|
||||||
|
// **Why this is not `useAsync`.** Core's hook (MODULE_API.md §3.4, and
|
||||||
|
// `client/src/lib/useAsync.js` in core) is `useState({loading:true,error:null,data:null})`
|
||||||
|
// re-run on a dependency change — and the first thing it does on every run is
|
||||||
|
// blank `data` and set `loading`. That is right for a page load and wrong for a
|
||||||
|
// poll: bumping a dependency every twenty seconds would clear the killfeed,
|
||||||
|
// render `<Loading />` in its place and re-fill it, four times a minute, for ever.
|
||||||
|
//
|
||||||
|
// So a poll needs a hook whose refresh is INVISIBLE when it succeeds. It keeps
|
||||||
|
// the previous rows on screen, replaces them when the new ones arrive, and keeps
|
||||||
|
// them *and* reports the error when the fetch fails — because a site whose whole
|
||||||
|
// premise is "it renders while the game is off" must not blank the page the
|
||||||
|
// first time a request does.
|
||||||
|
//
|
||||||
|
// `useAsync` is still the right hook for everything that loads once, and the
|
||||||
|
// pages here use it for exactly that. Bundling this beside it is the kit working
|
||||||
|
// as intended: the nine shared members are the chrome every module must share,
|
||||||
|
// not a ceiling on what a module may write.
|
||||||
|
//
|
||||||
|
// ── Two behaviours worth knowing ──────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// 1. **A backgrounded tab does not poll.** Page Visibility, plus an immediate
|
||||||
|
// refresh when the viewer comes back — which is also the moment stale rows
|
||||||
|
// are most visible. A tab left open overnight is otherwise a request every
|
||||||
|
// twenty seconds until the laptop dies.
|
||||||
|
// 2. **`key` resets, dependencies do not.** Switching server or wipe SHOULD
|
||||||
|
// blank the rows: what is on screen belongs to a different question. That is
|
||||||
|
// what `key` is for, and it is separate from the interval.
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {() => Promise<any>} fetcher called with no arguments; must not throw synchronously
|
||||||
|
* @param {object} options
|
||||||
|
* @param {string} options.key changes when the QUESTION changes, blanking the answer
|
||||||
|
* @param {number} options.intervalMs 0 disables polling — the hook then loads once
|
||||||
|
* @param {boolean} options.enabled false while the page has nothing to ask about yet
|
||||||
|
*/
|
||||||
|
export function usePolled(fetcher, { key = '', intervalMs = 20000, enabled = true } = {}) {
|
||||||
|
const [state, setState] = useState({ data: null, error: null, loading: enabled, at: null })
|
||||||
|
|
||||||
|
// The fetcher is rebuilt on every render — it closes over props — and a hook
|
||||||
|
// that listed it as a dependency would restart its interval every render. The
|
||||||
|
// ref is how the timer keeps calling the CURRENT one without depending on it.
|
||||||
|
const latest = useRef(fetcher)
|
||||||
|
latest.current = fetcher
|
||||||
|
|
||||||
|
// Guards a reply from a question nobody is asking any more: a slow request
|
||||||
|
// whose page has moved on, or one still in flight at unmount.
|
||||||
|
const generation = useRef(0)
|
||||||
|
|
||||||
|
const run = useCallback(
|
||||||
|
async (mine) => {
|
||||||
|
try {
|
||||||
|
const data = await latest.current()
|
||||||
|
if (mine !== generation.current) return
|
||||||
|
setState({ data, error: null, loading: false, at: Date.now() })
|
||||||
|
} catch (error) {
|
||||||
|
if (mine !== generation.current) return
|
||||||
|
// `data` is carried forward deliberately. A failed refresh is a page that
|
||||||
|
// says "this is what we last knew, and it did not refresh", which is the
|
||||||
|
// same promise the server list makes about a game server being down.
|
||||||
|
setState((prev) => ({ data: prev.data, error, loading: false, at: prev.at }))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const refresh = useCallback(() => run(generation.current), [run])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
generation.current += 1
|
||||||
|
const mine = generation.current
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
setState({ data: null, error: null, loading: false, at: null })
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
setState({ data: null, error: null, loading: true, at: null })
|
||||||
|
run(mine)
|
||||||
|
|
||||||
|
if (!intervalMs) return () => { generation.current += 1 }
|
||||||
|
|
||||||
|
let timer = null
|
||||||
|
|
||||||
|
const visible = () => typeof document === 'undefined' || document.visibilityState === 'visible'
|
||||||
|
|
||||||
|
const start = () => {
|
||||||
|
if (timer === null) timer = setInterval(() => run(mine), intervalMs)
|
||||||
|
}
|
||||||
|
const stop = () => {
|
||||||
|
if (timer !== null) { clearInterval(timer); timer = null }
|
||||||
|
}
|
||||||
|
|
||||||
|
const onVisibility = () => {
|
||||||
|
if (visible()) { run(mine); start() } else stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (visible()) start()
|
||||||
|
if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVisibility)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// Bumping the generation on teardown is what makes an in-flight reply from
|
||||||
|
// the old question land nowhere. Clearing the timer alone would not.
|
||||||
|
generation.current += 1
|
||||||
|
stop()
|
||||||
|
if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVisibility)
|
||||||
|
}
|
||||||
|
}, [key, intervalMs, enabled, run])
|
||||||
|
|
||||||
|
return { ...state, refresh }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default usePolled
|
||||||
65
client/src/icons.jsx
Normal file
65
client/src/icons.jsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
// ── The nav glyph for this module's player-portal row ─────────────────────
|
||||||
|
//
|
||||||
|
// `icon` is part of the nav-item contract (MODULE_API.md §3.3, 1.3.0): core
|
||||||
|
// renders whatever component a row carries, exactly as it renders its own rows'
|
||||||
|
// icons — and core's player portal draws a glyph on every row, so a row without
|
||||||
|
// one reads as breakage rather than as a design. The client suite asserts it.
|
||||||
|
//
|
||||||
|
// The public header is text buttons and carries no icons, which is why this file
|
||||||
|
// arrives with the player row and not before it.
|
||||||
|
//
|
||||||
|
// **The frame is copied from core's `PlayerPortalLayout`, deliberately and by
|
||||||
|
// copy rather than by import** — 16px, `currentColor`, stroke 2. Four attributes
|
||||||
|
// of presentation are not a component: putting them in the shared kit would
|
||||||
|
// freeze core's icon sizing into the contract, where changing it later would be a
|
||||||
|
// major bump. A module that wants to look like the nav it is in matches that nav.
|
||||||
|
|
||||||
|
const Icon = ({ children }) => (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
focusable="false"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A chain link — what the row is for.
|
||||||
|
*
|
||||||
|
* Not a gem, a person or a server: the portal's rows say what a player does
|
||||||
|
* there, and what a player does at `/player/rust` is link an account. Core's own
|
||||||
|
* neighbours are a gear (account), a shield (appeals) and a bell (notifications),
|
||||||
|
* so the row has to read as a verb in that company.
|
||||||
|
*/
|
||||||
|
export const IconLink = () => (
|
||||||
|
<Icon>
|
||||||
|
<path d="M10 13a5 5 0 007.07 0l2.83-2.83a5 5 0 00-7.07-7.07L11.5 4.5" />
|
||||||
|
<path d="M14 11a5 5 0 00-7.07 0L4.1 13.83a5 5 0 007.07 7.07L12.5 19.5" />
|
||||||
|
</Icon>
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key — the admin sidebar's row for the permission mirror.
|
||||||
|
*
|
||||||
|
* Core's admin groups are labelled by subject and drawn with glyphs of the same
|
||||||
|
* weight, so this is the same 16px frame as the portal's. A key rather than a
|
||||||
|
* shield: a shield is protection from something, and this row is about handing
|
||||||
|
* somebody the right to do something.
|
||||||
|
*/
|
||||||
|
export const IconKey = () => (
|
||||||
|
<Icon>
|
||||||
|
<circle cx="7.5" cy="15.5" r="4.5" />
|
||||||
|
<path d="M10.7 12.3L20 3" />
|
||||||
|
<path d="M17 6l2.5 2.5" />
|
||||||
|
</Icon>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default { IconLink, IconKey }
|
||||||
178
client/src/lib/feed.js
Normal file
178
client/src/lib/feed.js
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
// ── One stored frame as one line of a feed ────────────────────────────────
|
||||||
|
//
|
||||||
|
// `GET /public/rust/servers/:id/events` answers rows shaped
|
||||||
|
// `{ id, kind, t, wipeId, steamId, frame }`, where `frame` is the whole frame
|
||||||
|
// the plugin emitted — this module stores what it is given and indexes only the
|
||||||
|
// columns it serves (PROTOCOL.md §8.4, and the `raw` column in schema.sql). So
|
||||||
|
// everything a killfeed line needs is in `frame`, under the names the plugin
|
||||||
|
// wrote, and this file is the one place that knows them.
|
||||||
|
//
|
||||||
|
// **It returns PARTS, not a sentence.** A component wants the names emphasised
|
||||||
|
// and the detail muted, and a function returning `"Alice killed Bob"` forces
|
||||||
|
// either a `dangerouslySetInnerHTML` or a re-parse. Parts also make this
|
||||||
|
// testable without a DOM, which is the whole reason it is not a component.
|
||||||
|
//
|
||||||
|
// ── The rule for an unknown kind ──────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// It renders as itself. A later protocol adds kinds, an operator's module may be
|
||||||
|
// older than their game host, and a feed that DROPPED what it did not recognise
|
||||||
|
// would be a page that quietly says less than the truth. The server's allowlist
|
||||||
|
// has already decided this row may be seen (`server/catalogue.js`); what is left
|
||||||
|
// here is presentation, and the honest presentation of a kind we have no words
|
||||||
|
// for is its own name.
|
||||||
|
|
||||||
|
import { duration, prefab } from './format.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kinds this feed asks for.
|
||||||
|
*
|
||||||
|
* `player.tally` is public and deliberately NOT here: it is an aggregate the
|
||||||
|
* plugin flushes every sixty seconds per active player (§8.6), so a feed
|
||||||
|
* including it would be mostly wood counts. It is the leaderboard's input, and
|
||||||
|
* the leaderboard is where it shows up.
|
||||||
|
*/
|
||||||
|
export const FEED_KINDS = Object.freeze([
|
||||||
|
'player.death',
|
||||||
|
'player.connected',
|
||||||
|
'player.disconnected',
|
||||||
|
'player.respawned',
|
||||||
|
'player.chat',
|
||||||
|
'server.wipe',
|
||||||
|
'server.initialized',
|
||||||
|
'server.shutdown',
|
||||||
|
])
|
||||||
|
|
||||||
|
/** The filters the feed offers, and the kinds each one asks the API for. */
|
||||||
|
export const FILTERS = Object.freeze([
|
||||||
|
{ id: 'all', label: 'Everything', kinds: FEED_KINDS },
|
||||||
|
{ id: 'kills', label: 'Kills', kinds: ['player.death'] },
|
||||||
|
{ id: 'chat', label: 'Chat', kinds: ['player.chat'] },
|
||||||
|
{
|
||||||
|
id: 'sessions',
|
||||||
|
label: 'Comings and goings',
|
||||||
|
kinds: ['player.connected', 'player.disconnected', 'player.respawned'],
|
||||||
|
},
|
||||||
|
{ id: 'server', label: 'Server', kinds: ['server.wipe', 'server.initialized', 'server.shutdown'] },
|
||||||
|
])
|
||||||
|
|
||||||
|
export function kindsFor(filterId) {
|
||||||
|
const filter = FILTERS.find((f) => f.id === filterId)
|
||||||
|
return (filter || FILTERS[0]).kinds
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row as `{ tone, actor, join, verb, subject, detail }`.
|
||||||
|
*
|
||||||
|
* `actor` and `subject` are names and are emphasised; `verb` and `detail` are
|
||||||
|
* prose. Any of them may be empty. `tone` is the row's category, for the small
|
||||||
|
* colour the component gives it — never for deciding what a row means.
|
||||||
|
*
|
||||||
|
* `join` is what goes between the actor and the verb, and it exists for exactly
|
||||||
|
* one case: chat. "Brannock see you in september" is not a sentence anybody
|
||||||
|
* writes, and putting the colon in the message would put presentation inside the
|
||||||
|
* text a player typed.
|
||||||
|
*/
|
||||||
|
export function describe(row) {
|
||||||
|
const frame = (row && row.frame) || {}
|
||||||
|
const name = frame.name || null
|
||||||
|
|
||||||
|
switch (row && row.kind) {
|
||||||
|
case 'player.death':
|
||||||
|
return death(frame, name)
|
||||||
|
|
||||||
|
case 'player.connected':
|
||||||
|
return { tone: 'join', actor: name, verb: 'connected', subject: null, detail: '' }
|
||||||
|
|
||||||
|
case 'player.disconnected':
|
||||||
|
return {
|
||||||
|
tone: 'leave',
|
||||||
|
actor: name,
|
||||||
|
verb: 'disconnected',
|
||||||
|
subject: null,
|
||||||
|
// Two optional halves, and the session is the interesting one: the plugin
|
||||||
|
// omits `sessionSec` for a player who was already on when it loaded, so an
|
||||||
|
// absent value means "unknown", never zero (§8.4's note, and OnPlayerDisconnected).
|
||||||
|
detail: [frame.reason || null, frame.sessionSec ? `after ${duration(frame.sessionSec)}` : null]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · '),
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'player.respawned':
|
||||||
|
return { tone: 'join', actor: name, verb: 'respawned', subject: null, detail: '' }
|
||||||
|
|
||||||
|
case 'player.chat':
|
||||||
|
return {
|
||||||
|
tone: 'chat',
|
||||||
|
actor: name,
|
||||||
|
join: ': ',
|
||||||
|
// The message is the row, so it goes in `verb` where a component renders
|
||||||
|
// it unemphasised — and it is the one field on this wire a player chooses
|
||||||
|
// the bytes of. React escapes it; nothing here may ever stop doing that.
|
||||||
|
verb: frame.message || '',
|
||||||
|
subject: null,
|
||||||
|
detail: frame.channel && frame.channel !== 'Global' ? frame.channel : '',
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'server.wipe':
|
||||||
|
return {
|
||||||
|
tone: 'server',
|
||||||
|
actor: null,
|
||||||
|
verb: 'The map was wiped',
|
||||||
|
subject: null,
|
||||||
|
detail: frame.wipeId ? `new wipe ${frame.wipeId}` : '',
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'server.initialized':
|
||||||
|
return { tone: 'server', actor: null, verb: 'The server came up', subject: null, detail: '' }
|
||||||
|
|
||||||
|
case 'server.shutdown':
|
||||||
|
return { tone: 'server', actor: null, verb: 'The server went down', subject: null, detail: '' }
|
||||||
|
|
||||||
|
default:
|
||||||
|
return { tone: 'other', actor: name, verb: String((row && row.kind) || 'unknown'), subject: null, detail: '' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A death, which is four different sentences.
|
||||||
|
*
|
||||||
|
* The plugin distinguishes `player`, `self`, `npc` and `environment` precisely so
|
||||||
|
* that a reader does not have to guess from an absent field, and collapsing any
|
||||||
|
* two of them loses something (see `DescribeAttacker` in the bridge plugin). A
|
||||||
|
* killfeed that reported a fall as a kill by nobody is the failure this avoids.
|
||||||
|
*/
|
||||||
|
function death(frame, name) {
|
||||||
|
const where = [
|
||||||
|
frame.weapon ? `with ${prefab(frame.weapon)}` : null,
|
||||||
|
frame.distance ? `${Math.round(frame.distance)}m` : null,
|
||||||
|
frame.grid || null,
|
||||||
|
frame.sleeping ? 'while sleeping' : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')
|
||||||
|
|
||||||
|
switch (frame.attackerType) {
|
||||||
|
case 'player':
|
||||||
|
return { tone: 'kill', actor: frame.attackerName || null, verb: 'killed', subject: name, detail: where }
|
||||||
|
|
||||||
|
case 'self':
|
||||||
|
return { tone: 'death', actor: name, verb: 'died by their own hand', subject: null, detail: where }
|
||||||
|
|
||||||
|
case 'npc':
|
||||||
|
return {
|
||||||
|
tone: 'death',
|
||||||
|
actor: prefab(frame.attackerName) || 'Something',
|
||||||
|
verb: 'killed',
|
||||||
|
subject: name,
|
||||||
|
detail: where,
|
||||||
|
}
|
||||||
|
|
||||||
|
// `environment` and anything else: falling, drowning, the world. `HitInfo`
|
||||||
|
// is legitimately null on this path, so an absent attacker type is this case
|
||||||
|
// rather than a missing field to complain about.
|
||||||
|
default:
|
||||||
|
return { tone: 'death', actor: name, verb: 'died', subject: null, detail: where }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default { describe, FEED_KINDS, FILTERS, kindsFor }
|
||||||
136
client/src/lib/format.js
Normal file
136
client/src/lib/format.js
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
// ── Formatting, with no dependencies and no React ─────────────────────────
|
||||||
|
//
|
||||||
|
// Every function here is pure and takes what the API answered, so the suite next
|
||||||
|
// door can ask all of it without a DOM. That is deliberate: the client half's
|
||||||
|
// real failures are timing and resolution (see `test/build.test.js`), which a
|
||||||
|
// DOM-less runner cannot see — so the way to have any test coverage at all on
|
||||||
|
// this side is to keep the parts that CAN be tested free of React.
|
||||||
|
//
|
||||||
|
// `Intl` does the work. It is in every browser core supports, it knows the
|
||||||
|
// viewer's locale and their clock, and it is one fewer thing in a chunk an
|
||||||
|
// operator ships.
|
||||||
|
|
||||||
|
const RELATIVE = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
|
||||||
|
|
||||||
|
const UNITS = [
|
||||||
|
['year', 31536000],
|
||||||
|
['month', 2592000],
|
||||||
|
['week', 604800],
|
||||||
|
['day', 86400],
|
||||||
|
['hour', 3600],
|
||||||
|
['minute', 60],
|
||||||
|
['second', 1],
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "3 minutes ago", from an ISO string or an epoch-millisecond number.
|
||||||
|
*
|
||||||
|
* Both shapes arrive from this module's own API: `updatedAt` is an ISO string
|
||||||
|
* the model produced, and an event's `t` is the millisecond stamp the plugin put
|
||||||
|
* on the frame. Accepting both here is what stops every caller remembering which
|
||||||
|
* is which.
|
||||||
|
*/
|
||||||
|
export function ago(value, now = Date.now()) {
|
||||||
|
const at = toMillis(value)
|
||||||
|
if (at === null) return 'never'
|
||||||
|
|
||||||
|
const seconds = Math.round((at - now) / 1000)
|
||||||
|
const magnitude = Math.abs(seconds)
|
||||||
|
|
||||||
|
// Under a minute, "in 0 seconds" is what `numeric: 'auto'` produces and it is
|
||||||
|
// not what anybody means. Say the thing.
|
||||||
|
if (magnitude < 45) return 'just now'
|
||||||
|
|
||||||
|
const [unit, size] = UNITS.find(([, s]) => magnitude >= s) || ['second', 1]
|
||||||
|
return RELATIVE.format(Math.round(seconds / size), unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stamp on a feed row.
|
||||||
|
*
|
||||||
|
* **Today's rows get a time; everything older gets a date as well.** The feed can
|
||||||
|
* be filtered to a past wipe, and a row from six weeks ago rendered as `02:03 PM`
|
||||||
|
* reads as this afternoon — which the page walk found the moment it looked at the
|
||||||
|
* previous wipe: three events from August, all apparently a few minutes old.
|
||||||
|
*
|
||||||
|
* `now` is a parameter so the boundary is testable rather than a property of the
|
||||||
|
* machine the test runs on.
|
||||||
|
*/
|
||||||
|
export function clock(value, now = Date.now()) {
|
||||||
|
const at = toMillis(value)
|
||||||
|
if (at === null) return ''
|
||||||
|
|
||||||
|
const when = new Date(at)
|
||||||
|
const time = when.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
|
||||||
|
|
||||||
|
const today = new Date(now)
|
||||||
|
const sameDay =
|
||||||
|
when.getFullYear() === today.getFullYear() &&
|
||||||
|
when.getMonth() === today.getMonth() &&
|
||||||
|
when.getDate() === today.getDate()
|
||||||
|
|
||||||
|
if (sameDay) return time
|
||||||
|
return `${when.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} ${time}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A date, for a wipe: the thing people actually compare wipes by. */
|
||||||
|
export function day(value) {
|
||||||
|
const at = toMillis(value)
|
||||||
|
if (at === null) return 'unknown'
|
||||||
|
return new Date(at).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A session or a playtime, as `4h 12m`.
|
||||||
|
*
|
||||||
|
* Seconds are dropped above a minute and kept below it, because a two-hour
|
||||||
|
* session reported to the second is noise and a forty-second one reported as
|
||||||
|
* "0m" is wrong.
|
||||||
|
*/
|
||||||
|
export function duration(seconds) {
|
||||||
|
const total = Number(seconds)
|
||||||
|
if (!Number.isFinite(total) || total <= 0) return '—'
|
||||||
|
if (total < 60) return `${Math.round(total)}s`
|
||||||
|
|
||||||
|
const hours = Math.floor(total / 3600)
|
||||||
|
const minutes = Math.round((total % 3600) / 60)
|
||||||
|
|
||||||
|
if (hours === 0) return `${minutes}m`
|
||||||
|
return minutes === 0 ? `${hours}h` : `${hours}h ${minutes}m`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Thousands separators, in the viewer's locale. */
|
||||||
|
export function count(value) {
|
||||||
|
const n = Number(value)
|
||||||
|
return Number.isFinite(n) ? n.toLocaleString() : '0'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A prefab short name as something readable — `patrolhelicopter` stays itself,
|
||||||
|
* `rifle.ak` becomes `rifle ak`.
|
||||||
|
*
|
||||||
|
* Deliberately a light touch rather than a lookup table. A table mapping every
|
||||||
|
* Rust prefab to a pretty name is a second copy of the game's item list that
|
||||||
|
* goes stale every wipe, and the short name is what a Rust player reads on their
|
||||||
|
* own server console anyway.
|
||||||
|
*/
|
||||||
|
export function prefab(name) {
|
||||||
|
if (!name) return ''
|
||||||
|
return String(name).replace(/[_.]+/g, ' ').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A steam id, shortened for a table cell, without pretending it is a name. */
|
||||||
|
export function shortId(steamId) {
|
||||||
|
const id = String(steamId || '')
|
||||||
|
return id.length > 10 ? `…${id.slice(-6)}` : id
|
||||||
|
}
|
||||||
|
|
||||||
|
function toMillis(value) {
|
||||||
|
if (value === null || value === undefined || value === '') return null
|
||||||
|
if (typeof value === 'number') return Number.isFinite(value) ? value : null
|
||||||
|
|
||||||
|
const parsed = Date.parse(value)
|
||||||
|
return Number.isNaN(parsed) ? null : parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
export default { ago, clock, day, duration, count, prefab, shortId }
|
||||||
617
client/src/routes/admin/Permissions.jsx
Normal file
617
client/src/routes/admin/Permissions.jsx
Normal file
@@ -0,0 +1,617 @@
|
|||||||
|
// ── Admin · Rust · Permissions ────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// R2's authoring surface, and this module's first admin page.
|
||||||
|
//
|
||||||
|
// **What is on it is decided by what an operator can get wrong**, rather than by
|
||||||
|
// what the tables contain. Four states are invisible from the game and from a
|
||||||
|
// list of grants, and every one of them looks exactly like success:
|
||||||
|
//
|
||||||
|
// • a grant against somebody who has linked no Steam account — authored,
|
||||||
|
// stored, pushed nowhere;
|
||||||
|
// • a permission no loaded plugin has registered — the grant lands silently
|
||||||
|
// nowhere, because `GrantUserPermission` no-ops for an unregistered name;
|
||||||
|
// • a group member who has never connected — the store has no user record to
|
||||||
|
// put in a group yet, and the membership waits for their first connection;
|
||||||
|
// • a server whose last sync failed — the site is authoritative and the game
|
||||||
|
// has not heard it.
|
||||||
|
//
|
||||||
|
// So each of those is a sentence on this page rather than a number in a report.
|
||||||
|
//
|
||||||
|
// The screen never writes to a game. Every button here writes to the site and
|
||||||
|
// the mirror's loop reconciles within seconds — except *Sync now*, which runs
|
||||||
|
// that pass immediately because an operator who has just changed something
|
||||||
|
// should not have to trust a timer to find out that a host is unreachable.
|
||||||
|
|
||||||
|
import { useCallback, useState } from 'react'
|
||||||
|
|
||||||
|
import { ErrorState, Loading, useAsync } from '../../core.js'
|
||||||
|
import { ago } from '../../lib/format.js'
|
||||||
|
import api from '../../api.js'
|
||||||
|
|
||||||
|
const FLEET = '*'
|
||||||
|
|
||||||
|
/** Shared furniture. The kit is nine exports and none of them is a table. */
|
||||||
|
function Card({ title, subtitle, children, actions }) {
|
||||||
|
return (
|
||||||
|
<section className="panel" style={{ padding: '16px 18px', marginBottom: 18 }}>
|
||||||
|
<header style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginBottom: 12 }}>
|
||||||
|
<h2 className="display" style={{ fontSize: '1.05rem', margin: 0, color: 'var(--head)' }}>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{subtitle && (
|
||||||
|
<span className="sans dim" style={{ fontSize: '0.76rem' }}>
|
||||||
|
{subtitle}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
{actions}
|
||||||
|
</header>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ children, muted = false }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="sans"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
padding: '8px 0',
|
||||||
|
borderTop: '1px solid var(--line-soft)',
|
||||||
|
fontSize: '0.86rem',
|
||||||
|
color: muted ? 'var(--ink)' : 'var(--head)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Warn({ children }) {
|
||||||
|
return (
|
||||||
|
<p className="sans" style={{ color: '#d08a2a', fontSize: '0.78rem', margin: '6px 0 0' }}>
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Scope({ value }) {
|
||||||
|
return (
|
||||||
|
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
|
||||||
|
{value === FLEET ? 'every server' : value}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One server's mirror state.
|
||||||
|
*
|
||||||
|
* `unresolved` and `pending` are rendered as sentences rather than counts
|
||||||
|
* because each is a different problem with a different fix, and both are
|
||||||
|
* invisible everywhere else on this page.
|
||||||
|
*/
|
||||||
|
function ServerState({ row, onSync, busy }) {
|
||||||
|
const report = row.report || {}
|
||||||
|
const unresolved = report.unresolved || []
|
||||||
|
const pending = report.pending || []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '10px 0', borderTop: '1px solid var(--line-soft)' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<span className="sans" style={{ color: 'var(--head)', fontSize: '0.9rem' }}>
|
||||||
|
{row.serverId}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="sans"
|
||||||
|
style={{ fontSize: '0.76rem', color: row.inSync ? 'var(--ink)' : '#d08a2a' }}
|
||||||
|
>
|
||||||
|
{row.inSync ? 'in sync' : row.state === 'failed' ? 'out of sync' : 'pending'}
|
||||||
|
</span>
|
||||||
|
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
|
||||||
|
{row.lastOkAt ? `last pushed ${ago(row.lastOkAt)}` : 'never pushed'}
|
||||||
|
</span>
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
<button type="button" className="btn ghost" onClick={() => onSync(row.serverId)} disabled={busy}>
|
||||||
|
{busy ? 'Syncing…' : 'Sync now'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{row.error && (
|
||||||
|
<p className="sans" style={{ color: '#e05a5a', fontSize: '0.78rem', margin: '4px 0 0' }}>
|
||||||
|
{row.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{unresolved.length > 0 && (
|
||||||
|
<Warn>
|
||||||
|
{unresolved.join(', ')} — no plugin loaded on this server has registered{' '}
|
||||||
|
{unresolved.length === 1 ? 'that name' : 'those names'}, so a grant naming{' '}
|
||||||
|
{unresolved.length === 1 ? 'it' : 'them'} reaches nobody here. It will land by itself when
|
||||||
|
the plugin is back.
|
||||||
|
</Warn>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pending.length > 0 && (
|
||||||
|
<Warn>
|
||||||
|
{pending.length} {pending.length === 1 ? 'membership is' : 'memberships are'} waiting on a
|
||||||
|
first connection — this server has never seen those players, so it has no account to put
|
||||||
|
in a group yet.
|
||||||
|
</Warn>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A hand edit, with the two answers to it. */
|
||||||
|
function DriftRow({ row, onAdopt, onRevoke, busy }) {
|
||||||
|
const subject = row.username ? `${row.username} (${row.subject})` : row.subject
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Row>
|
||||||
|
<span style={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<strong style={{ fontWeight: 500 }}>{row.object}</strong>{' '}
|
||||||
|
<span className="dim" style={{ fontSize: '0.78rem' }}>
|
||||||
|
{row.kind === 'group-permission' ? `on group ${row.subject}` : `held by ${subject}`} ·{' '}
|
||||||
|
{row.serverId} · seen {ago(row.firstSeen)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<button type="button" className="btn ghost" onClick={() => onAdopt(row)} disabled={busy}>
|
||||||
|
Adopt
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn ghost" onClick={() => onRevoke(row)} disabled={busy}>
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
</Row>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The memberships the game could not place yet, as `steamId:group`.
|
||||||
|
*
|
||||||
|
* Read out of each server's own report, because it is the only thing that knows:
|
||||||
|
* a member who has never connected to a server has no user record there to put
|
||||||
|
* in a group (§12.2 rule 4), and from every other angle they look like a member.
|
||||||
|
* The server strip says how many; this is what puts it next to the person.
|
||||||
|
*/
|
||||||
|
function pendingSet(servers) {
|
||||||
|
const pending = new Map()
|
||||||
|
|
||||||
|
for (const server of servers) {
|
||||||
|
for (const entry of (server.report && server.report.pending) || []) {
|
||||||
|
if (!pending.has(entry)) pending.set(entry, [])
|
||||||
|
pending.get(entry).push(server.serverId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pending
|
||||||
|
}
|
||||||
|
|
||||||
|
function GroupCard({ group, catalogue, servers, pending, onChanged, setError }) {
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [member, setMember] = useState('')
|
||||||
|
const [permission, setPermission] = useState('')
|
||||||
|
|
||||||
|
const act = async (fn) => {
|
||||||
|
setBusy(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await fn()
|
||||||
|
await onChanged()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'That did not work.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const save = (permissions) =>
|
||||||
|
act(() =>
|
||||||
|
api.adminPermissions.saveGroup(group.name, {
|
||||||
|
title: group.title,
|
||||||
|
rank: group.rank,
|
||||||
|
scope: group.scope,
|
||||||
|
permissions,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
title={group.title || group.name}
|
||||||
|
subtitle={<>{group.name} · <Scope value={group.scope} /></>}
|
||||||
|
actions={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn ghost"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => act(() => api.adminPermissions.deleteGroup(group.name))}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="field-label">Permissions</div>
|
||||||
|
{group.permissions.length === 0 && (
|
||||||
|
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '4px 0' }}>
|
||||||
|
This group carries nothing, so being in it does nothing.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{group.permissions.map((perm) => (
|
||||||
|
<Row key={perm}>
|
||||||
|
<span style={{ flex: 1 }}>{perm}</span>
|
||||||
|
{!catalogue.some((entry) => entry.permission === perm) && (
|
||||||
|
<span className="sans" style={{ color: '#d08a2a', fontSize: '0.74rem' }}>
|
||||||
|
no server has registered this
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn ghost"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => save(group.permissions.filter((p) => p !== perm))}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</Row>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<form
|
||||||
|
style={{ display: 'flex', gap: 8, marginTop: 10 }}
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!permission.trim()) return
|
||||||
|
save([...group.permissions, permission.trim().toLowerCase()])
|
||||||
|
setPermission('')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
list="rust-permission-names"
|
||||||
|
className="input"
|
||||||
|
placeholder="kits.vip"
|
||||||
|
value={permission}
|
||||||
|
onChange={(event) => setPermission(event.target.value)}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<button type="submit" className="btn" disabled={busy}>
|
||||||
|
Add permission
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="field-label" style={{ marginTop: 18 }}>
|
||||||
|
Members
|
||||||
|
</div>
|
||||||
|
{group.members.length === 0 && (
|
||||||
|
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '4px 0' }}>
|
||||||
|
Nobody is in this group.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{group.members.map((m) => {
|
||||||
|
const waiting = m.accounts
|
||||||
|
.map((account) => pending.get(`${account.steamId}:${group.name}`))
|
||||||
|
.filter(Boolean)
|
||||||
|
.flat()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Row key={m.userId}>
|
||||||
|
<span style={{ flex: 1 }}>
|
||||||
|
{m.username}
|
||||||
|
{m.accounts.length > 0 ? (
|
||||||
|
<span className="dim" style={{ fontSize: '0.76rem' }}>
|
||||||
|
{' '}
|
||||||
|
· {m.accounts.map((a) => a.name || a.steamId).join(', ')}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: '#d08a2a', fontSize: '0.76rem' }}>
|
||||||
|
{' '}
|
||||||
|
· has linked no Steam account, so this reaches nobody
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{waiting.length > 0 && (
|
||||||
|
<span style={{ color: '#d08a2a', fontSize: '0.76rem' }}>
|
||||||
|
{' '}
|
||||||
|
· waiting on their first connection to {[...new Set(waiting)].join(', ')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn ghost"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => act(() => api.adminPermissions.removeMember(group.name, m.userId))}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</Row>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
<form
|
||||||
|
style={{ display: 'flex', gap: 8, marginTop: 10 }}
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!member.trim()) return
|
||||||
|
act(() => api.adminPermissions.addMember(group.name, member.trim()))
|
||||||
|
setMember('')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
placeholder="website username"
|
||||||
|
value={member}
|
||||||
|
onChange={(event) => setMember(event.target.value)}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<button type="submit" className="btn" disabled={busy}>
|
||||||
|
Add member
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{servers.length > 1 && group.scope !== FLEET && (
|
||||||
|
<p className="sans dim" style={{ fontSize: '0.74rem', margin: '10px 0 0' }}>
|
||||||
|
This group exists on {group.scope} only. The other servers never receive it.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Permissions() {
|
||||||
|
const [reloads, setReloads] = useState(0)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [form, setForm] = useState({ name: '', title: '', scope: FLEET })
|
||||||
|
const [grant, setGrant] = useState({ username: '', permission: '', scope: FLEET })
|
||||||
|
|
||||||
|
const { data, error: loadError } = useAsync(() => api.adminPermissions.overview(), [reloads])
|
||||||
|
const reload = useCallback(() => setReloads((n) => n + 1), [])
|
||||||
|
|
||||||
|
const act = async (fn) => {
|
||||||
|
setBusy(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await fn()
|
||||||
|
reload()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'That did not work.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadError) return <ErrorState error={loadError} />
|
||||||
|
if (!data) return <Loading />
|
||||||
|
|
||||||
|
const servers = data.servers || []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 900 }}>
|
||||||
|
{/* No heading of our own: core's admin chrome already draws the route's
|
||||||
|
title above the page, and a second one is the same words twice. */}
|
||||||
|
<p className="sans dim" style={{ fontSize: '0.82rem', marginTop: 0 }}>
|
||||||
|
This site is the author of record. Groups and grants written here are pushed into each
|
||||||
|
server’s own permission store, so every plugin that checks a permission honours them — and a
|
||||||
|
wipe does not lose them, because they are re-pushed when the server comes back.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* The option source, shared by both forms. A datalist rather than a select:
|
||||||
|
a name that no server has registered is still authorable — the plugin
|
||||||
|
may simply not be loaded right now — and the warning beside it is the
|
||||||
|
honest treatment, where a closed list would be a refusal. */}
|
||||||
|
<datalist id="rust-permission-names">
|
||||||
|
{(data.catalogue || []).map((entry) => (
|
||||||
|
<option key={entry.permission} value={entry.permission} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="sans" style={{ color: '#e05a5a', fontSize: '0.84rem' }}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card
|
||||||
|
title="Servers"
|
||||||
|
subtitle={`${servers.length} configured`}
|
||||||
|
actions={
|
||||||
|
<button type="button" className="btn ghost" disabled={busy} onClick={() => act(() => api.adminPermissions.sync())}>
|
||||||
|
Sync all
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{servers.length === 0 && (
|
||||||
|
<p className="sans dim" style={{ fontSize: '0.82rem', margin: 0 }}>
|
||||||
|
No servers are configured yet, so nothing written here reaches a game.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{servers.map((row) => (
|
||||||
|
<ServerState
|
||||||
|
key={row.serverId}
|
||||||
|
row={row}
|
||||||
|
busy={busy}
|
||||||
|
onSync={(id) => act(() => api.adminPermissions.sync(id))}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{(data.drift || []).length > 0 && (
|
||||||
|
<Card
|
||||||
|
title="Changed in game"
|
||||||
|
subtitle="granted at a console, not by this site"
|
||||||
|
>
|
||||||
|
<p className="sans dim" style={{ fontSize: '0.8rem', marginTop: 0 }}>
|
||||||
|
Nothing here is undone automatically. <strong>Adopt</strong> records it as the site’s
|
||||||
|
own, so it survives the next wipe; <strong>Revoke</strong> removes it from the game on
|
||||||
|
the next sync.
|
||||||
|
</p>
|
||||||
|
{data.drift.map((row) => (
|
||||||
|
<DriftRow
|
||||||
|
key={row.id}
|
||||||
|
row={row}
|
||||||
|
busy={busy}
|
||||||
|
onAdopt={(d) => act(() => api.adminPermissions.adoptDrift(d.id))}
|
||||||
|
onRevoke={(d) => act(() => api.adminPermissions.revokeDrift(d.id))}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card title="Direct grants" subtitle="one person, one permission">
|
||||||
|
{(data.grants || []).length === 0 && (
|
||||||
|
<p className="sans dim" style={{ fontSize: '0.82rem', margin: 0 }}>
|
||||||
|
Nobody holds a permission of their own yet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{(data.grants || []).map((row) => (
|
||||||
|
<Row key={row.id}>
|
||||||
|
<span style={{ flex: 1 }}>
|
||||||
|
{row.username} · <strong style={{ fontWeight: 500 }}>{row.permission}</strong>{' '}
|
||||||
|
<Scope value={row.scope} />
|
||||||
|
{row.accounts.length === 0 && (
|
||||||
|
<span style={{ color: '#d08a2a', fontSize: '0.76rem' }}>
|
||||||
|
{' '}
|
||||||
|
· has linked no Steam account, so this reaches nobody
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{/* The same warning the group's permission list carries, and it
|
||||||
|
matters more here: a grant naming a permission nothing has
|
||||||
|
registered is the failure the plugin's pre-check exists for,
|
||||||
|
and it is invisible on this row without it. */}
|
||||||
|
{!(data.catalogue || []).some((entry) => entry.permission === row.permission) && (
|
||||||
|
<span style={{ color: '#d08a2a', fontSize: '0.76rem' }}>
|
||||||
|
{' '}
|
||||||
|
· no server has registered this permission
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{row.source !== 'admin' && (
|
||||||
|
<span className="dim" style={{ fontSize: '0.74rem' }}> · {row.source}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn ghost"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => act(() => api.adminPermissions.revoke(row.id))}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</Row>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<form
|
||||||
|
style={{ display: 'flex', gap: 8, marginTop: 12, flexWrap: 'wrap' }}
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!grant.username.trim() || !grant.permission.trim()) return
|
||||||
|
act(() =>
|
||||||
|
api.adminPermissions.grant({
|
||||||
|
username: grant.username.trim(),
|
||||||
|
permission: grant.permission.trim().toLowerCase(),
|
||||||
|
scope: grant.scope,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
setGrant({ username: '', permission: '', scope: FLEET })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
placeholder="website username"
|
||||||
|
value={grant.username}
|
||||||
|
onChange={(event) => setGrant({ ...grant, username: event.target.value })}
|
||||||
|
style={{ flex: '1 1 160px' }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
list="rust-permission-names"
|
||||||
|
className="input"
|
||||||
|
placeholder="kits.vip"
|
||||||
|
value={grant.permission}
|
||||||
|
onChange={(event) => setGrant({ ...grant, permission: event.target.value })}
|
||||||
|
style={{ flex: '1 1 160px' }}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={grant.scope}
|
||||||
|
onChange={(event) => setGrant({ ...grant, scope: event.target.value })}
|
||||||
|
>
|
||||||
|
<option value={FLEET}>every server</option>
|
||||||
|
{servers.map((row) => (
|
||||||
|
<option key={row.serverId} value={row.serverId}>
|
||||||
|
{row.serverId}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button type="submit" className="btn" disabled={busy}>
|
||||||
|
Grant
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{(data.groups || []).map((group) => (
|
||||||
|
<GroupCard
|
||||||
|
key={group.name}
|
||||||
|
group={group}
|
||||||
|
catalogue={data.catalogue || []}
|
||||||
|
servers={servers}
|
||||||
|
pending={pendingSet(servers)}
|
||||||
|
onChanged={reload}
|
||||||
|
setError={setError}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Card title="New group">
|
||||||
|
<form
|
||||||
|
style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!form.name.trim()) return
|
||||||
|
act(() =>
|
||||||
|
api.adminPermissions.saveGroup(form.name.trim().toLowerCase(), {
|
||||||
|
title: form.title.trim() || form.name.trim(),
|
||||||
|
scope: form.scope,
|
||||||
|
permissions: [],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
setForm({ name: '', title: '', scope: FLEET })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
placeholder="vip"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(event) => setForm({ ...form, name: event.target.value })}
|
||||||
|
style={{ flex: '1 1 140px' }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
placeholder="VIP"
|
||||||
|
value={form.title}
|
||||||
|
onChange={(event) => setForm({ ...form, title: event.target.value })}
|
||||||
|
style={{ flex: '1 1 140px' }}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={form.scope}
|
||||||
|
onChange={(event) => setForm({ ...form, scope: event.target.value })}
|
||||||
|
>
|
||||||
|
<option value={FLEET}>every server</option>
|
||||||
|
{servers.map((row) => (
|
||||||
|
<option key={row.serverId} value={row.serverId}>
|
||||||
|
{row.serverId}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button type="submit" className="btn" disabled={busy}>
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p className="sans dim" style={{ fontSize: '0.74rem', margin: '10px 0 0' }}>
|
||||||
|
A group is created in each in-scope game as a real group, so plugins that read group
|
||||||
|
membership see it. A member who has never connected to a server joins it there on their
|
||||||
|
first connection — a direct grant reaches them straight away, which is the difference
|
||||||
|
worth knowing when somebody is waiting.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
284
client/src/routes/admin/UserRustSections.jsx
Normal file
284
client/src/routes/admin/UserRustSections.jsx
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
// ── This module's fill for `admin.users.detail` ───────────────────────────
|
||||||
|
//
|
||||||
|
// R13's first slot, and the phase criterion as an operator meets it: the Steam
|
||||||
|
// id inside core's own user page, under core's own security panel.
|
||||||
|
//
|
||||||
|
// **The slot hands over `userId` and nothing else** — not a client. So this file
|
||||||
|
// builds its own bindings for the routes the server half registered
|
||||||
|
// (`api.adminUserLinks`), which is §3.5's rule applied to a slot: the two ends of
|
||||||
|
// a call belong to the same module even when the URL between them is core's.
|
||||||
|
//
|
||||||
|
// **Most users have no Rust account, so most of the time this renders nothing.**
|
||||||
|
// A panel that announced "no linked Steam accounts" on every user page in a
|
||||||
|
// community that also runs a UO shard would be noise on the overwhelming
|
||||||
|
// majority of them. Silence is the honest answer to "what does the Rust module
|
||||||
|
// know about this person" when it is nothing.
|
||||||
|
|
||||||
|
import { useCallback, useState } from 'react'
|
||||||
|
import { ago, count, duration } from '../../lib/format.js'
|
||||||
|
import { useAsync } from '../../core.js'
|
||||||
|
import api from '../../api.js'
|
||||||
|
|
||||||
|
/** Six lines of furniture the §3.4 kit does not carry, so it is vendored. */
|
||||||
|
function SectionTitle({ children }) {
|
||||||
|
return (
|
||||||
|
<div className="field-label" style={{ marginBottom: 12, marginTop: 4 }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One server's all-time totals for this player. */
|
||||||
|
function ServerRow({ server }) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
className="sans"
|
||||||
|
style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.86rem', color: 'var(--ink)' }}
|
||||||
|
>
|
||||||
|
<span style={{ minWidth: 0, color: 'var(--head)' }}>{server.serverName}</span>
|
||||||
|
<span className="dim" style={{ flex: 'none', fontSize: '0.8rem' }}>
|
||||||
|
{count(server.kills)} kills · {count(server.deaths)} deaths · {duration(server.playtimeSec)}
|
||||||
|
{server.wipes > 1 ? ` · ${server.wipes} wipes` : ''}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One linked Steam account: who it is, when it was linked, and the way out. */
|
||||||
|
function LinkPanel({ userId, link, onRemoved }) {
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
async function unlink() {
|
||||||
|
setBusy(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await api.adminUserLinks.remove(userId, link.steamId)
|
||||||
|
await onRemoved()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not unlink that account.')
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="panel" style={{ padding: '14px 16px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 14 }}>
|
||||||
|
<div style={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<div className="display" style={{ fontSize: '1rem', color: 'var(--head)' }}>
|
||||||
|
{link.name || link.steamId}
|
||||||
|
</div>
|
||||||
|
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
|
||||||
|
{link.steamId} · linked {ago(link.linkedAt)}
|
||||||
|
{link.serverId ? ` on ${link.serverId}` : ''}
|
||||||
|
{link.lastSeen ? ` · last played ${ago(link.lastSeen)}` : ' · never played'}
|
||||||
|
</div>
|
||||||
|
{/* Worth showing only when they differ: the name on the link is what
|
||||||
|
they were called when they linked, the other is what the game last
|
||||||
|
saw. A rename is the ordinary reason, and an operator reading a
|
||||||
|
support ticket wants both names. */}
|
||||||
|
{link.linkedName && link.name && link.linkedName !== link.name && (
|
||||||
|
<div className="sans dim" style={{ fontSize: '0.72rem', marginTop: 2 }}>
|
||||||
|
Linked as “{link.linkedName}”.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn ghost" onClick={unlink} disabled={busy} style={{ flex: 'none' }}>
|
||||||
|
{busy ? 'Unlinking…' : 'Unlink'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="sans" style={{ color: '#e05a5a', fontSize: '0.8rem', margin: '8px 0 0' }}>{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{link.servers.length > 0 && (
|
||||||
|
<ul
|
||||||
|
style={{
|
||||||
|
listStyle: 'none',
|
||||||
|
margin: '12px 0 0',
|
||||||
|
padding: '12px 0 0',
|
||||||
|
borderTop: '1px solid var(--line-soft)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{link.servers.map((server) => (
|
||||||
|
<ServerRow key={server.serverId} server={server} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 7's half of the panel: what this person may do in game.
|
||||||
|
*
|
||||||
|
* It renders whenever they hold anything, INCLUDING when they have linked no
|
||||||
|
* Steam account — which is the one case worth going out of the way for. A grant
|
||||||
|
* against an unlinked person is authored, stored, pushed nowhere, and identical
|
||||||
|
* to a working one everywhere except here.
|
||||||
|
*/
|
||||||
|
function PermissionsPanel({ userId, data, onChanged }) {
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [permission, setPermission] = useState('')
|
||||||
|
|
||||||
|
const act = async (fn) => {
|
||||||
|
setBusy(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await fn()
|
||||||
|
await onChanged()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'That did not work.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) return null
|
||||||
|
|
||||||
|
const nothing = data.groups.length === 0 && data.grants.length === 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="panel" style={{ padding: '14px 16px' }}>
|
||||||
|
<div className="field-label" style={{ marginBottom: 8 }}>
|
||||||
|
Permissions
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{nothing && (
|
||||||
|
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 8px' }}>
|
||||||
|
Nothing granted.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data.groups.map((group) => (
|
||||||
|
<div key={group.name} className="sans" style={{ fontSize: '0.84rem', padding: '4px 0' }}>
|
||||||
|
<span style={{ color: 'var(--head)' }}>{group.title || group.name}</span>{' '}
|
||||||
|
<span className="dim" style={{ fontSize: '0.76rem' }}>
|
||||||
|
group · {group.scope === '*' ? 'every server' : group.scope}
|
||||||
|
{group.permissions.length ? ` · ${group.permissions.join(', ')}` : ' · carries nothing'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{data.grants.map((row) => (
|
||||||
|
<div
|
||||||
|
key={row.id}
|
||||||
|
className="sans"
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: '0.84rem', padding: '4px 0' }}
|
||||||
|
>
|
||||||
|
<span style={{ flex: 1, color: 'var(--head)' }}>
|
||||||
|
{row.permission}{' '}
|
||||||
|
<span className="dim" style={{ fontSize: '0.76rem' }}>
|
||||||
|
{row.scope === '*' ? 'every server' : row.scope}
|
||||||
|
{row.source !== 'admin' ? ` · ${row.source}` : ''}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn ghost"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => act(() => api.adminUserPermissions.revoke(userId, row.id))}
|
||||||
|
style={{ flex: 'none' }}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{!nothing && data.reaches.length === 0 && (
|
||||||
|
<p className="sans" style={{ color: '#d08a2a', fontSize: '0.78rem', margin: '8px 0 0' }}>
|
||||||
|
This account has linked no Steam id, so none of it reaches a game yet. It will apply by
|
||||||
|
itself when they link.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form
|
||||||
|
style={{ display: 'flex', gap: 8, marginTop: 10 }}
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!permission.trim()) return
|
||||||
|
act(() =>
|
||||||
|
api.adminUserPermissions.grant(userId, { permission: permission.trim().toLowerCase() }),
|
||||||
|
)
|
||||||
|
setPermission('')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
placeholder="kits.vip"
|
||||||
|
value={permission}
|
||||||
|
onChange={(event) => setPermission(event.target.value)}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<button type="submit" className="btn" disabled={busy}>
|
||||||
|
Grant
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="sans" style={{ color: '#e05a5a', fontSize: '0.8rem', margin: '8px 0 0' }}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UserRustSections({ userId }) {
|
||||||
|
// Core's `useAsync` has no refresh, so a counter in the deps is how this
|
||||||
|
// re-reads after its own write (the same shape the player page uses).
|
||||||
|
const [reloads, setReloads] = useState(0)
|
||||||
|
const { data } = useAsync(() => api.adminUserLinks.list(userId), [userId, reloads])
|
||||||
|
const { data: permissions } = useAsync(
|
||||||
|
() => api.adminUserPermissions.list(userId),
|
||||||
|
[userId, reloads],
|
||||||
|
)
|
||||||
|
const reload = useCallback(() => setReloads((n) => n + 1), [])
|
||||||
|
|
||||||
|
// No `Loading` and no `ErrorState`, deliberately. This is a section inside
|
||||||
|
// somebody else's page: a spinner on every user page for a module most users
|
||||||
|
// have nothing to do with is worse than a section that appears when it has
|
||||||
|
// something, and a failure here must not replace core's own user detail with an
|
||||||
|
// error card.
|
||||||
|
// **Both reads decide whether this section exists**, and the second one is the
|
||||||
|
// reason. A browser walk found it: a person can hold permissions and have
|
||||||
|
// linked no Steam account — which is exactly the state an operator most needs
|
||||||
|
// to see, because it is the one that reaches nobody — and a section gated on
|
||||||
|
// links alone hides it completely.
|
||||||
|
const holdsSomething =
|
||||||
|
permissions && (permissions.groups.length > 0 || permissions.grants.length > 0)
|
||||||
|
|
||||||
|
if (!data || (data.links.length === 0 && !holdsSomething)) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
|
||||||
|
<SectionTitle>Rust</SectionTitle>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
{data.links.map((link) => (
|
||||||
|
<LinkPanel key={link.steamId} userId={userId} link={link} onRemoved={reload} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{data.links.length > 0 && (
|
||||||
|
<p className="sans dim" style={{ fontSize: '0.74rem', margin: 0 }}>
|
||||||
|
A link is fleet-wide and totals are all-time, summed across every wipe. Unlinking here is
|
||||||
|
recorded in the activity log — it is the way back for a player who linked the wrong
|
||||||
|
account and cannot reach it in game.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Inside the same section rather than beside it: "who is this in game"
|
||||||
|
and "what may they do there" are one question asked twice, and an
|
||||||
|
operator reading a support ticket has both in front of them. The note
|
||||||
|
above belongs to the links, so it sits with them rather than under
|
||||||
|
the panel it would otherwise appear to describe. */}
|
||||||
|
<PermissionsPanel userId={userId} data={permissions} onChanged={reload} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
191
client/src/routes/player/Account.jsx
Normal file
191
client/src/routes/player/Account.jsx
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
// ── The player's own Rust identity ────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// `/player/rust` — where a signed-in player links the Steam account they play
|
||||||
|
// on. It is the one page in this module a player is asked to *do* something on,
|
||||||
|
// and the thing they are doing matters more than it looks: from phase 7 the link
|
||||||
|
// is what in-game permissions are granted against, and from phase 13 it is what
|
||||||
|
// rewards are handed to.
|
||||||
|
//
|
||||||
|
// **A player route renders no layout of its own.** Core wraps `/player/*` in its
|
||||||
|
// own portal chrome, so this page starts at a heading — unlike the public pages
|
||||||
|
// in this module, which render `PublicLayout` themselves.
|
||||||
|
//
|
||||||
|
// The three-step instruction at the top is not decoration. Nothing else on the
|
||||||
|
// site tells a player that the code comes from the game, and a code field with no
|
||||||
|
// explanation is a code field nobody can use.
|
||||||
|
|
||||||
|
import { useCallback, useState } from 'react'
|
||||||
|
import { ErrorState, Loading, useAsync } from '../../core.js'
|
||||||
|
import { ago, shortId } from '../../lib/format.js'
|
||||||
|
import api from '../../api.js'
|
||||||
|
|
||||||
|
/** The code field, and the four answers it can produce. */
|
||||||
|
function LinkForm({ onLinked }) {
|
||||||
|
const [code, setCode] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [message, setMessage] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
async function submit(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!code.trim() || busy) return
|
||||||
|
|
||||||
|
setBusy(true)
|
||||||
|
setMessage('')
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api.playerLinks.confirm(code.trim())
|
||||||
|
setMessage(
|
||||||
|
result.already
|
||||||
|
? 'That account was already linked to you.'
|
||||||
|
: `Linked ${result.link.name || shortId(result.link.steamId)}.`,
|
||||||
|
)
|
||||||
|
setCode('')
|
||||||
|
await onLinked()
|
||||||
|
} catch (err) {
|
||||||
|
// Every refusal the server sends is already a sentence aimed at a player —
|
||||||
|
// "run /link again", "run /unlink in game", "try again in a minute" — so
|
||||||
|
// this renders it rather than replacing it with one of its own. The three
|
||||||
|
// are not interchangeable, and a page that flattened them into "could not
|
||||||
|
// link that code" would send a player back to the server that is down.
|
||||||
|
setError(err.message || 'Could not link that code.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={submit} style={{ marginTop: 18 }}>
|
||||||
|
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||||
|
<label style={{ display: 'block' }}>
|
||||||
|
<span className="field-label" style={{ display: 'block', marginBottom: 6 }}>Link code</span>
|
||||||
|
<input
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
||||||
|
placeholder="K7M2PQ"
|
||||||
|
// The plugin's alphabet has no O, 0, I or 1, so a player reading a
|
||||||
|
// code off their screen cannot produce one — but they can type a
|
||||||
|
// lowercase one, and the code is matched case-insensitively at the
|
||||||
|
// other end. Upper-casing here makes what they typed look like what
|
||||||
|
// they were shown.
|
||||||
|
maxLength={12}
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
className="input"
|
||||||
|
style={{ textTransform: 'uppercase', letterSpacing: '0.18em', width: 160 }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" className="btn" disabled={busy || !code.trim()}>
|
||||||
|
{busy ? 'Checking…' : 'Link account'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<p className="sans" style={{ color: '#7fd0a4', fontSize: '0.86rem', margin: '10px 0 0' }}>{message}</p>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<p className="sans" style={{ color: '#e05a5a', fontSize: '0.86rem', margin: '10px 0 0' }}>{error}</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One linked account, and the control that releases it. */
|
||||||
|
function LinkRow({ link, onRemoved }) {
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
async function remove() {
|
||||||
|
setBusy(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await api.playerLinks.remove(link.steamId)
|
||||||
|
await onRemoved()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not unlink that account.')
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="panel" style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||||
|
<div style={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<div className="display" style={{ fontSize: '1rem', color: 'var(--head)' }}>
|
||||||
|
{link.name || shortId(link.steamId)}
|
||||||
|
</div>
|
||||||
|
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
|
||||||
|
{link.steamId} · linked {ago(link.linkedAt)}
|
||||||
|
{link.serverId ? ` on ${link.serverId}` : ''}
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p className="sans" style={{ color: '#e05a5a', fontSize: '0.8rem', margin: '6px 0 0' }}>{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn ghost" onClick={remove} disabled={busy} style={{ flex: 'none' }}>
|
||||||
|
{busy ? 'Unlinking…' : 'Unlink'}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Account() {
|
||||||
|
// `useAsync` rather than this module's `usePolled`: nothing here changes unless
|
||||||
|
// the person looking at it changes it, and a page that re-asked every twenty
|
||||||
|
// seconds would be asking a question nobody is waiting on.
|
||||||
|
//
|
||||||
|
// **Core's `useAsync` has no `refresh`** — it re-runs when its deps change and
|
||||||
|
// that is the whole of its interface — so a counter in the deps is how a page
|
||||||
|
// re-reads after its own write. It blanks while it re-reads, which is right
|
||||||
|
// here and is exactly what made it wrong for a poll (see `hooks/usePolled.js`).
|
||||||
|
const [reloads, setReloads] = useState(0)
|
||||||
|
const { data, loading, error } = useAsync(() => api.playerLinks.list(), [reloads])
|
||||||
|
const links = data ? data.links : []
|
||||||
|
|
||||||
|
const reload = useCallback(() => setReloads((n) => n + 1), [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="field-label" style={{ marginBottom: 12 }}>Steam accounts</div>
|
||||||
|
|
||||||
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem', maxWidth: '60ch' }}>
|
||||||
|
Linking tells this site which Steam account is yours, so your play on our servers appears
|
||||||
|
under your name here — and so rewards and permissions the site hands out can reach you in
|
||||||
|
game.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ol className="sans dim" style={{ fontSize: '0.86rem', marginTop: 14, paddingLeft: 20, maxWidth: '60ch' }}>
|
||||||
|
<li>Join any of our Rust servers and type <code>/link</code> in chat.</li>
|
||||||
|
<li>The server replies with a six-character code, only you can see it, and it lasts five minutes.</li>
|
||||||
|
<li>Type it below. It works once.</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<LinkForm onLinked={reload} />
|
||||||
|
|
||||||
|
{loading && <Loading />}
|
||||||
|
{error && <ErrorState error={error} />}
|
||||||
|
|
||||||
|
{data && links.length > 0 && (
|
||||||
|
<ul style={{ listStyle: 'none', margin: '22px 0 0', padding: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{links.map((link) => (
|
||||||
|
<LinkRow key={link.steamId} link={link} onRemoved={reload} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data && links.length > 0 && (
|
||||||
|
<p className="sans dim" style={{ fontSize: '0.76rem', marginTop: 14, maxWidth: '60ch' }}>
|
||||||
|
A link covers every server this community runs — a Steam account is one person wherever
|
||||||
|
they play, while stats are kept per server and per wipe. You can also type
|
||||||
|
{' '}<code>/unlink</code> in game to release one.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data && links.length === 0 && (
|
||||||
|
<p className="sans dim" style={{ fontSize: '0.8rem', marginTop: 18 }}>
|
||||||
|
No Steam account is linked to this profile yet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
184
client/src/routes/public/ServerDetail.jsx
Normal file
184
client/src/routes/public/ServerDetail.jsx
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
// ── One server ────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// R8's page beneath the landing page, and the phase-4 criterion lives here: it
|
||||||
|
// renders the last thing this server said while every server is off. Nothing on
|
||||||
|
// it is a live call to a game host — every panel reads this module's own tables,
|
||||||
|
// filled by the ingest cursor — so a shard that has been down for a week renders
|
||||||
|
// a week-old killfeed and a leaderboard that is still correct, rather than an
|
||||||
|
// error page.
|
||||||
|
//
|
||||||
|
// ── Everything selectable is in the URL ───────────────────────────────────
|
||||||
|
//
|
||||||
|
// Tab, feed filter, wipe and leaderboard sort all live in search parameters.
|
||||||
|
// That costs a little ceremony here and buys the thing a community site is for:
|
||||||
|
// "look at last wipe's leaderboard on Main" is a LINK. State held in `useState`
|
||||||
|
// would make every one of those sentences unlinkable, lose the reader's place on
|
||||||
|
// a refresh, and make the browser's back button leave the page instead of
|
||||||
|
// undoing what they just clicked.
|
||||||
|
//
|
||||||
|
// `useSearchParams` comes from CORE's router (the shim in `src/shim/`), so it is
|
||||||
|
// the same live navigation context core's own pages use. A module with its own
|
||||||
|
// copy of react-router would get a `useParams` that returns nothing on a page
|
||||||
|
// that otherwise renders perfectly — see `core.js`'s identity check.
|
||||||
|
|
||||||
|
import { useSearchParams, useParams, Link } from 'react-router-dom'
|
||||||
|
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
|
||||||
|
import Feed from '../../components/Feed.jsx'
|
||||||
|
import Leaderboard from '../../components/Leaderboard.jsx'
|
||||||
|
import Online from '../../components/Online.jsx'
|
||||||
|
import Tabs from '../../components/Tabs.jsx'
|
||||||
|
import WipeSelect, { ALL_TIME } from '../../components/WipeSelect.jsx'
|
||||||
|
import Wipes from '../../components/Wipes.jsx'
|
||||||
|
import { ago, count, day } from '../../lib/format.js'
|
||||||
|
import api from '../../api.js'
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ id: 'feed', label: 'Feed' },
|
||||||
|
{ id: 'leaderboard', label: 'Leaderboard' },
|
||||||
|
{ id: 'online', label: 'Online' },
|
||||||
|
{ id: 'wipes', label: 'Wipes' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function ServerDetail() {
|
||||||
|
const { id } = useParams()
|
||||||
|
const [params, setParams] = useSearchParams()
|
||||||
|
|
||||||
|
const { data, loading, error } = useAsync(() => api.servers.get(id), [id])
|
||||||
|
const server = data ? data.server : null
|
||||||
|
|
||||||
|
const tab = TABS.some((t) => t.id === params.get('tab')) ? params.get('tab') : 'feed'
|
||||||
|
const filter = params.get('show') || 'all'
|
||||||
|
const sort = params.get('sort') || 'kills'
|
||||||
|
|
||||||
|
// `wipe` absent means all time; `wipe=current` means whatever wipe the server
|
||||||
|
// is on now, which is a moving target and therefore a word rather than an id —
|
||||||
|
// a link somebody shares stays about "now" rather than about the map that was
|
||||||
|
// current when they sent it.
|
||||||
|
const wipeParam = params.get('wipe')
|
||||||
|
const wipeId = !wipeParam || wipeParam === ALL_TIME ? null : wipeParam === 'current' ? (server && server.wipeId) || null : wipeParam
|
||||||
|
|
||||||
|
const set = (key, value) => {
|
||||||
|
const next = new URLSearchParams(params)
|
||||||
|
if (!value || value === 'all' || (key === 'tab' && value === 'feed')) next.delete(key)
|
||||||
|
else next.set(key, value)
|
||||||
|
// `replace` so that flipping between tabs does not fill the reader's history
|
||||||
|
// with one entry per click — back should leave the page they arrived on.
|
||||||
|
setParams(next, { replace: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<PublicLayout shell="mid">
|
||||||
|
<Loading />
|
||||||
|
</PublicLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A 404 from the detail route is the one answer the other four cannot give:
|
||||||
|
// an unknown id has no events, no leaderboard and nobody online, and each of
|
||||||
|
// those empty lists is a perfectly good answer to its own question. So this is
|
||||||
|
// where "there is no such server" is said.
|
||||||
|
//
|
||||||
|
// **A mistyped address is not a fault, and must not be dressed as one.** The
|
||||||
|
// first version of this page rendered core's `ErrorState` under the heading and
|
||||||
|
// the result read "No such server / Something went wrong" — which sends a
|
||||||
|
// reader who fat-fingered a URL looking for an outage. `ErrorState` is kept for
|
||||||
|
// the case it is for: a request that failed for a reason nobody can see.
|
||||||
|
if (error || !server) {
|
||||||
|
const missing = !error || error.status === 404
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PublicLayout shell="mid">
|
||||||
|
<PageHeader
|
||||||
|
title={missing ? 'No such server' : 'That server could not be loaded'}
|
||||||
|
lead={
|
||||||
|
missing
|
||||||
|
? 'This address does not name a server this site follows.'
|
||||||
|
: 'The site could not read this server just now. It is worth trying again.'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{!missing && <ErrorState error={error} />}
|
||||||
|
<p className="sans" style={{ marginTop: 20 }}>
|
||||||
|
<Link to="/rust">Back to the server list</Link>
|
||||||
|
</p>
|
||||||
|
</PublicLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PublicLayout shell="mid">
|
||||||
|
<PageHeader
|
||||||
|
eyebrow="Rust"
|
||||||
|
title={server.name}
|
||||||
|
lead={describeWorld(server)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="sans"
|
||||||
|
style={{ display: 'flex', flexWrap: 'wrap', gap: 16, alignItems: 'baseline', marginBottom: 24 }}
|
||||||
|
>
|
||||||
|
<span style={{ color: server.online ? 'var(--mode-live, #5fb98a)' : 'var(--dim)' }}>
|
||||||
|
{server.online
|
||||||
|
? `${count(server.players)}${server.maxPlayers ? ` / ${count(server.maxPlayers)}` : ''} online`
|
||||||
|
: 'Offline'}
|
||||||
|
</span>
|
||||||
|
{/* `lastSeenAt` is when a frame arrived; `updatedAt` is when this site
|
||||||
|
last wrote the row, which a FAILED poll does too. Reading the second
|
||||||
|
as the first is what made an offline server claim it had reported just
|
||||||
|
now, every thirty seconds, for as long as it stayed down. */}
|
||||||
|
<span style={{ color: 'var(--dim)', fontSize: '0.8rem' }}>
|
||||||
|
{server.lastSeenAt ? `last reported ${ago(server.lastSeenAt)}` : 'has never reported'}
|
||||||
|
{server.stale && server.lastSeenAt ? ' — out of date, so it is shown as offline' : ''}
|
||||||
|
</span>
|
||||||
|
<span style={{ marginLeft: 'auto' }}>
|
||||||
|
<WipeSelect
|
||||||
|
serverId={server.id}
|
||||||
|
value={wipeParam}
|
||||||
|
currentWipeId={server.wipeId}
|
||||||
|
onChange={(value) => set('wipe', value === ALL_TIME ? null : value)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs tabs={TABS} active={tab} onSelect={(next) => set('tab', next)} label={`${server.name} sections`} />
|
||||||
|
|
||||||
|
{tab === 'feed' && (
|
||||||
|
<Feed serverId={server.id} wipeId={wipeId} filter={filter} onFilter={(value) => set('show', value)} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'leaderboard' && (
|
||||||
|
<Leaderboard serverId={server.id} wipeId={wipeId} sort={sort} onSort={(value) => set('sort', value)} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'online' && <Online serverId={server.id} online={server.online} />}
|
||||||
|
|
||||||
|
{tab === 'wipes' && (
|
||||||
|
<Wipes
|
||||||
|
serverId={server.id}
|
||||||
|
currentWipeId={server.wipeId}
|
||||||
|
selected={wipeId}
|
||||||
|
// Picking a wipe here is a navigation as much as a filter: it is the
|
||||||
|
// question "what happened during that map", and the answer is the feed.
|
||||||
|
onSelect={(value) => {
|
||||||
|
const next = new URLSearchParams(params)
|
||||||
|
next.set('wipe', value)
|
||||||
|
next.delete('tab')
|
||||||
|
setParams(next, { replace: true })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</PublicLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The world line under the heading — the things a Rust player asks first. */
|
||||||
|
function describeWorld(server) {
|
||||||
|
const parts = [
|
||||||
|
server.level || null,
|
||||||
|
server.worldSize ? `size ${count(server.worldSize)}` : null,
|
||||||
|
server.seed ? `seed ${server.seed}` : null,
|
||||||
|
server.wipedAt ? `wiped ${day(server.wipedAt)}` : null,
|
||||||
|
].filter(Boolean)
|
||||||
|
|
||||||
|
return parts.length > 0 ? parts.join(' · ') : 'This server has not described itself yet.'
|
||||||
|
}
|
||||||
124
client/src/routes/public/Servers.jsx
Normal file
124
client/src/routes/public/Servers.jsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
// ── The server list, and the module's landing page ────────────────────────
|
||||||
|
//
|
||||||
|
// R8: the list is what `/rust` renders, and `/rust/servers/:id` hangs beneath
|
||||||
|
// it. The route is registered with an empty path in `entry.jsx` — core turns
|
||||||
|
// that into the module's own namespace root — so this page's address is the one
|
||||||
|
// an operator links to when they mean "our Rust servers".
|
||||||
|
//
|
||||||
|
// An ordinary React component. Nothing about being inside a module changes how
|
||||||
|
// you write one; the only differences are where React comes from (core, via the
|
||||||
|
// aliases in `vite.config.js`, so the import below looks completely normal and is
|
||||||
|
// not) and where the chrome comes from (`../../core.js`, the shared UI kit).
|
||||||
|
//
|
||||||
|
// **Render `PublicLayout` yourself, and pass a `shell`.** Core wraps public
|
||||||
|
// routes in its maintenance gate and nothing else, so a page that omits the
|
||||||
|
// layout renders bare; without a `shell` it renders full-bleed with the footer
|
||||||
|
// riding up underneath it. Name a width, never a class — the classes are core's
|
||||||
|
// (MODULE_API.md §3.3).
|
||||||
|
//
|
||||||
|
// **This page never calls a game server.** Every field it renders comes from
|
||||||
|
// this module's own tables, written by the ingest cursor, which is what lets it
|
||||||
|
// render "offline, last seen an hour ago" instead of an error page when a shard
|
||||||
|
// is down. The site's availability does not depend on the game's.
|
||||||
|
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { EmptyState, ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
|
||||||
|
import { ago, count, day } from '../../lib/format.js'
|
||||||
|
import api from '../../api.js'
|
||||||
|
|
||||||
|
/** The "last reported" line, which has three cases and not one. */
|
||||||
|
function reported(server) {
|
||||||
|
if (!server.lastSeenAt) return 'This server has never reported.'
|
||||||
|
if (server.stale) return `Last reported ${ago(server.lastSeenAt)} — out of date, so it is shown as offline.`
|
||||||
|
return `Last reported ${ago(server.lastSeenAt)}.`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Servers() {
|
||||||
|
// `useAsync` is core's fetch/loading/error hook, and the components below are
|
||||||
|
// its states. Using them rather than rolling your own is what makes a module
|
||||||
|
// page indistinguishable from a core one while it loads and while it fails.
|
||||||
|
//
|
||||||
|
// It loads once, deliberately. The DETAIL page polls, because that is where
|
||||||
|
// somebody watching a server sits; a list is a place people pass through.
|
||||||
|
const { data, loading, error } = useAsync(() => api.servers.list(), [])
|
||||||
|
const servers = data ? data.servers : []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PublicLayout shell="mid">
|
||||||
|
<PageHeader
|
||||||
|
// `lead`, not `subtitle`. PageHeader takes `eyebrow`, `title`, `lead` and
|
||||||
|
// `center`, and an unknown prop on a React component is silently dropped
|
||||||
|
// — so a page written with `subtitle` renders its title and nothing else,
|
||||||
|
// on a site where every core page has a line under its heading.
|
||||||
|
title="Servers"
|
||||||
|
lead="Every Rust server this community runs, as each one last reported itself"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{loading && <Loading />}
|
||||||
|
{error && <ErrorState error={error} />}
|
||||||
|
|
||||||
|
{/* An operator who has configured no servers is not an error and not an
|
||||||
|
empty game — it is an install that is not finished. Saying so beats a
|
||||||
|
blank page that looks like a failure. */}
|
||||||
|
{data && servers.length === 0 && (
|
||||||
|
<EmptyState
|
||||||
|
title="No servers yet"
|
||||||
|
message="An administrator adds a Rust server, and its sidecar, from the admin panel."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{servers.length > 0 && (
|
||||||
|
<div style={{ display: 'grid', gap: 12 }}>
|
||||||
|
{servers.map((server) => (
|
||||||
|
// The whole row is the link. A server's name being the only clickable
|
||||||
|
// part is the thing people miss on a list of cards, and `a.card`
|
||||||
|
// already carries core's own hover treatment.
|
||||||
|
<Link
|
||||||
|
key={server.id}
|
||||||
|
to={`/rust/servers/${encodeURIComponent(server.id)}`}
|
||||||
|
className="card"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'baseline',
|
||||||
|
gap: '1rem',
|
||||||
|
padding: '16px 20px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<strong style={{ color: 'var(--ink)' }}>{server.name}</strong>
|
||||||
|
<span className="sans" style={{ display: 'block', color: 'var(--dim)', fontSize: '0.78rem', marginTop: 4 }}>
|
||||||
|
{[
|
||||||
|
server.level || null,
|
||||||
|
server.worldSize ? `size ${count(server.worldSize)}` : null,
|
||||||
|
server.wipedAt ? `wiped ${day(server.wipedAt)}` : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')}
|
||||||
|
</span>
|
||||||
|
<span className="sans" style={{ display: 'block', color: 'var(--dim)', fontSize: '0.74rem', marginTop: 2 }}>
|
||||||
|
{/* `lastSeenAt`, never `updatedAt`. The second is when THIS
|
||||||
|
site last wrote the row — which a failed poll does too — so
|
||||||
|
a page reading it told a reader that a server down for three
|
||||||
|
days had reported just now. And `stale` is a first-class
|
||||||
|
part of the answer rather than something inferred from a
|
||||||
|
timestamp: the server decides what counts as stale, because
|
||||||
|
the server knows how often a sidecar is supposed to check in. */}
|
||||||
|
{reported(server)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="sans"
|
||||||
|
style={{ whiteSpace: 'nowrap', color: server.online ? 'var(--mode-live, #5fb98a)' : 'var(--dim)' }}
|
||||||
|
>
|
||||||
|
{server.online
|
||||||
|
? `${count(server.players)}${server.maxPlayers ? ` / ${count(server.maxPlayers)}` : ''} online`
|
||||||
|
: 'Offline'}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PublicLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
16
client/src/shim/jsx-runtime.js
Normal file
16
client/src/shim/jsx-runtime.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
// `react/jsx-runtime`, from core.
|
||||||
|
//
|
||||||
|
// Every .jsx file this module compiles becomes imports from `react/jsx-runtime`
|
||||||
|
// under the automatic runtime, which is the default the tooling assumes. Those
|
||||||
|
// have to resolve to CORE's React like every other import — a second jsx runtime
|
||||||
|
// bound to a second React is the same one-React violation as bundling `react`
|
||||||
|
// itself, only harder to see, because it shows up as a hook dispatcher error in
|
||||||
|
// a component that looks fine.
|
||||||
|
|
||||||
|
import { rg } from './rg.js'
|
||||||
|
|
||||||
|
const jsxRuntime = rg().jsxRuntime
|
||||||
|
|
||||||
|
export const { jsx, jsxs, jsxDEV, Fragment } = jsxRuntime
|
||||||
|
|
||||||
|
export default jsxRuntime.default ?? jsxRuntime
|
||||||
14
client/src/shim/react-dom.js
vendored
Normal file
14
client/src/shim/react-dom.js
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// `react-dom/client`, from core.
|
||||||
|
//
|
||||||
|
// A module never calls `createRoot` — core owns the root and the module renders
|
||||||
|
// inside it. This exists because a transitive import can still reach for
|
||||||
|
// react-dom, and one that resolved to a bundled copy would put a second
|
||||||
|
// renderer in the page.
|
||||||
|
|
||||||
|
import { rg } from './rg.js'
|
||||||
|
|
||||||
|
const reactDom = rg().reactDom
|
||||||
|
|
||||||
|
export default reactDom.default ?? reactDom
|
||||||
|
|
||||||
|
export const { createRoot, hydrateRoot, flushSync, createPortal } = reactDom
|
||||||
32
client/src/shim/react-router-dom.js
vendored
Normal file
32
client/src/shim/react-router-dom.js
vendored
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
// `react-router-dom`, from core.
|
||||||
|
//
|
||||||
|
// The sharpest of the four, because router state is not just a library — it is
|
||||||
|
// one live navigation context. A module with its own copy would get a router
|
||||||
|
// whose `useParams` returns nothing and whose `<Link>` navigates the browser
|
||||||
|
// instead of the SPA, on a page that otherwise renders perfectly.
|
||||||
|
|
||||||
|
import { rg } from './rg.js'
|
||||||
|
|
||||||
|
const router = rg().router
|
||||||
|
|
||||||
|
export default router.default ?? router
|
||||||
|
|
||||||
|
export const {
|
||||||
|
BrowserRouter,
|
||||||
|
Link,
|
||||||
|
NavLink,
|
||||||
|
Navigate,
|
||||||
|
Outlet,
|
||||||
|
Route,
|
||||||
|
Routes,
|
||||||
|
createSearchParams,
|
||||||
|
generatePath,
|
||||||
|
matchPath,
|
||||||
|
useLocation,
|
||||||
|
useMatch,
|
||||||
|
useNavigate,
|
||||||
|
useOutletContext,
|
||||||
|
useParams,
|
||||||
|
useResolvedPath,
|
||||||
|
useSearchParams,
|
||||||
|
} = router
|
||||||
50
client/src/shim/react.js
vendored
Normal file
50
client/src/shim/react.js
vendored
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
// The shared React, taken from core rather than bundled.
|
||||||
|
//
|
||||||
|
// Why a shim file exists at all (MODULE_API.md §3.6, and the spike proved it the
|
||||||
|
// hard way): Rollup's `external` alone emits a bare `import 'react'` into the
|
||||||
|
// chunk, which the browser cannot resolve without an import map — and an import
|
||||||
|
// map has to be an inline `<script type="importmap">`, which core's
|
||||||
|
// `script-src 'self'` forbids. `output.globals` does not help either; it is
|
||||||
|
// iife/umd only, and this is an ES module. So each shared dependency is aliased
|
||||||
|
// to a two-line module that re-exports from the global core published before any
|
||||||
|
// module chunk evaluated.
|
||||||
|
//
|
||||||
|
// The named re-exports are not decoration: `import { useState } from 'react'`
|
||||||
|
// compiles to a named import, and a module with only a default export would fail
|
||||||
|
// at link time in the browser with a message about the binding, not about this.
|
||||||
|
|
||||||
|
import { rg } from './rg.js'
|
||||||
|
|
||||||
|
const react = rg().react
|
||||||
|
|
||||||
|
export default react.default ?? react
|
||||||
|
|
||||||
|
export const {
|
||||||
|
Children,
|
||||||
|
Component,
|
||||||
|
Fragment,
|
||||||
|
StrictMode,
|
||||||
|
Suspense,
|
||||||
|
cloneElement,
|
||||||
|
createContext,
|
||||||
|
createElement,
|
||||||
|
forwardRef,
|
||||||
|
isValidElement,
|
||||||
|
lazy,
|
||||||
|
memo,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useDebugValue,
|
||||||
|
useDeferredValue,
|
||||||
|
useEffect,
|
||||||
|
useId,
|
||||||
|
useImperativeHandle,
|
||||||
|
useInsertionEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useReducer,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
useSyncExternalStore,
|
||||||
|
useTransition,
|
||||||
|
} = react
|
||||||
29
client/src/shim/rg.js
Normal file
29
client/src/shim/rg.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
// The one place this module reads `window.__rg`, and the one place that says
|
||||||
|
// something useful when it is not there.
|
||||||
|
//
|
||||||
|
// Every shim beside this file, and `src/core.js`, go through here. That is not
|
||||||
|
// tidiness — it removes an ordering dependency that was genuinely fragile. ES
|
||||||
|
// modules evaluate dependencies in the source order of their import statements,
|
||||||
|
// so "put the friendly check in the file that is imported first" is a guarantee
|
||||||
|
// that survives exactly until someone sorts the imports. Whichever module the
|
||||||
|
// bundler happens to reach first, it reaches `window.__rg` through this.
|
||||||
|
//
|
||||||
|
// A missing global means core did not publish its shared dependencies before
|
||||||
|
// this chunk evaluated: an injection or ordering fault in CORE (MODULE_API.md
|
||||||
|
// §3.1), not a fault in this module. Without this, the first symptom is
|
||||||
|
// "Cannot read properties of undefined (reading 'react')" thrown from a file
|
||||||
|
// called react.js, which reads like the module bundled React wrong — the
|
||||||
|
// opposite of what happened.
|
||||||
|
export function rg() {
|
||||||
|
const shared = window.__rg
|
||||||
|
if (!shared) {
|
||||||
|
throw new Error(
|
||||||
|
'[rust] window.__rg is missing — core did not publish its shared dependencies before this ' +
|
||||||
|
'chunk evaluated. That is an injection or ordering fault in core (MODULE_API.md §3.1), not a ' +
|
||||||
|
'fault in this module.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return shared
|
||||||
|
}
|
||||||
|
|
||||||
|
export default rg
|
||||||
154
client/test/build.test.js
Normal file
154
client/test/build.test.js
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
// What can be checked about the client half without a browser.
|
||||||
|
//
|
||||||
|
// Not much, and being honest about that is the point: the client half's real
|
||||||
|
// failures are timing and resolution, and neither has a shape a DOM-less test
|
||||||
|
// runner can see. MODULE_API.md §7.7's four-step browser smoke is what actually
|
||||||
|
// proves this half works, and it is re-run whenever this seam changes.
|
||||||
|
//
|
||||||
|
// What IS testable here is the configuration that decides resolution — and one
|
||||||
|
// of these tests exists because the trap it guards cost this project real time: Vite's object-form `resolve.alias` does PREFIX matching, so a `react`
|
||||||
|
// key silently also rewrites `react/jsx-runtime`. An anchored regexp in the
|
||||||
|
// array form cannot. That is a property of the config, and a test can hold it.
|
||||||
|
|
||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const CLIENT = path.resolve(HERE, '..')
|
||||||
|
|
||||||
|
const { bareImports, problemsWith } = await import('../scripts/checkExternals.js')
|
||||||
|
const configModule = await import('../vite.config.js')
|
||||||
|
const config = configModule.default
|
||||||
|
const { SHARED, SHARED_PACKAGES: guardedPackages } = configModule
|
||||||
|
|
||||||
|
test('every alias is an anchored regexp, never a bare prefix string', () => {
|
||||||
|
const aliases = config.resolve.alias
|
||||||
|
assert.ok(Array.isArray(aliases), 'alias must use the ARRAY form — the object form prefix-matches')
|
||||||
|
for (const { find } of aliases) {
|
||||||
|
assert.ok(find instanceof RegExp, `alias "${find}" is a string; a string prefix-matches`)
|
||||||
|
assert.ok(find.source.startsWith('^') && find.source.endsWith('$'), `alias ${find} is not anchored`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('react and react/jsx-runtime resolve to different shims', () => {
|
||||||
|
// The exact collision the object form causes. Asserted on the outcome rather
|
||||||
|
// than on the config's shape, so it keeps holding however the config is
|
||||||
|
// rewritten.
|
||||||
|
const resolve = (specifier) =>
|
||||||
|
config.resolve.alias.find(({ find }) => find.test(specifier))?.replacement
|
||||||
|
assert.ok(resolve('react'))
|
||||||
|
assert.ok(resolve('react/jsx-runtime'))
|
||||||
|
assert.notStrictEqual(resolve('react'), resolve('react/jsx-runtime'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('every shared dependency is aliased', () => {
|
||||||
|
for (const specifier of ['react', 'react/jsx-runtime', 'react-dom', 'react-dom/client', 'react-router-dom']) {
|
||||||
|
assert.ok(
|
||||||
|
config.resolve.alias.some(({ find }) => find.test(specifier)),
|
||||||
|
`${specifier} is not aliased — it would be bundled, giving the page a second copy`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rollup external stays empty — it preempts the aliases rather than backing them up', () => {
|
||||||
|
// Rollup asks `external` BEFORE Vite's alias resolver runs, so a specifier
|
||||||
|
// listed in both is marked external and never aliased. The chunk then ships
|
||||||
|
// bare `import 'react'`, which no browser can resolve without an import map
|
||||||
|
// and CSP forbids one. §3.6 shows both; they do not compose.
|
||||||
|
assert.deepStrictEqual(config.build.rollupOptions.external, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the not-bundled guard covers every shared specifier and is not derived from them', () => {
|
||||||
|
// The direction of this dependency is the finding. Deriving the forbidden
|
||||||
|
// package list FROM the alias list means deleting an alias also deletes the
|
||||||
|
// guard against what that alias prevented — which is precisely when the guard
|
||||||
|
// is needed. So the guard states the contract, and this asserts the aliases
|
||||||
|
// stay inside it.
|
||||||
|
const packages = new Set(guardedPackages)
|
||||||
|
for (const { specifier } of SHARED) {
|
||||||
|
const pkg = specifier.startsWith('@') ? specifier.split('/').slice(0, 2).join('/') : specifier.split('/')[0]
|
||||||
|
assert.ok(packages.has(pkg), `${pkg} is aliased but not guarded against being bundled`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('every alias points at a shim file that exists', () => {
|
||||||
|
for (const { find, replacement } of config.resolve.alias) {
|
||||||
|
assert.ok(fs.existsSync(replacement), `alias ${find} points at a missing file: ${replacement}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the build emits one unhashed entry.js, which is what module.json names', () => {
|
||||||
|
assert.deepStrictEqual(config.build.lib.formats, ['es'])
|
||||||
|
assert.strictEqual(config.build.lib.fileName(), 'entry.js')
|
||||||
|
const manifest = JSON.parse(fs.readFileSync(path.resolve(CLIENT, '..', 'module.json'), 'utf8'))
|
||||||
|
assert.strictEqual(manifest.client.entry, 'client/dist/entry.js')
|
||||||
|
assert.strictEqual(config.build.outDir, 'dist')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('modulePreload polyfilling stays off — an inline bootstrap is refused under CSP', () => {
|
||||||
|
assert.strictEqual(config.build.modulePreload.polyfill, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('exactly one file reads window.__rg, and every shim goes through it', () => {
|
||||||
|
// `shim/rg.js` is the single reader, and that is not tidiness: it is what
|
||||||
|
// makes the "core did not publish its dependencies" message reachable. The
|
||||||
|
// shims touch the global before anything else in the chunk does, so a check
|
||||||
|
// placed in the first-imported file is a guarantee that lasts until someone
|
||||||
|
// sorts the imports.
|
||||||
|
const dir = path.join(CLIENT, 'src', 'shim')
|
||||||
|
const shims = fs.readdirSync(dir)
|
||||||
|
assert.ok(shims.length >= 5)
|
||||||
|
for (const file of shims) {
|
||||||
|
const source = fs.readFileSync(path.join(dir, file), 'utf8')
|
||||||
|
const code = source.replace(/^\s*\/\/.*$/gm, '') // the comments discuss the global
|
||||||
|
if (file === 'rg.js') {
|
||||||
|
assert.match(code, /window\.__rg/, 'rg.js must be the one that reads the global')
|
||||||
|
assert.doesNotMatch(code, /^\s*import\s/m, 'rg.js imports something')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
assert.doesNotMatch(code, /window\.__rg/, `${file} reads the global directly instead of via rg()`)
|
||||||
|
assert.match(code, /rg\(\)/, `${file} does not resolve through rg()`)
|
||||||
|
// A shim may import its sibling helper and nothing else — anything further
|
||||||
|
// would be a shim with a dependency to resolve, the problem it exists to remove.
|
||||||
|
for (const [, spec] of code.matchAll(/^\s*import\s[^'"]*['"]([^'"]+)['"]/gm)) {
|
||||||
|
assert.strictEqual(spec, './rg.js', `${file} imports ${spec}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the built chunk has no bare imports and bundles no shared dependency', () => {
|
||||||
|
// The artifact check itself, over the artifact that ships. Skipped rather than
|
||||||
|
// failed when there is no build: `npm test` must be runnable before `npm run
|
||||||
|
// build`, and CI runs them in order.
|
||||||
|
const chunk = path.join(CLIENT, 'dist', 'entry.js')
|
||||||
|
if (!fs.existsSync(chunk)) return
|
||||||
|
assert.deepStrictEqual(problemsWith(fs.readFileSync(chunk, 'utf8')), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an import inside a string is not an import — the check reads code, not text', () => {
|
||||||
|
// The regression that made this necessary: the first chunk with real content
|
||||||
|
// in it had a button labelled "Approve and import" put the token
|
||||||
|
// immediately before a quote. The check rejected the whole build, naming a
|
||||||
|
// fragment of minified JSX as the offending specifier.
|
||||||
|
const uiCopy = 'const a=n("button",{children:"Approve and import"}),b=1;'
|
||||||
|
assert.deepStrictEqual(bareImports(uiCopy), [])
|
||||||
|
|
||||||
|
// Neither is one in a comment, or in a template literal.
|
||||||
|
assert.deepStrictEqual(bareImports('// import "react" would be wrong here\nconst a=1'), [])
|
||||||
|
assert.deepStrictEqual(bareImports('/* import "react" */ const a=1'), [])
|
||||||
|
assert.deepStrictEqual(bareImports('const s=`import "react"`'), [])
|
||||||
|
|
||||||
|
// And a real one still is, in each form the build could emit.
|
||||||
|
assert.deepStrictEqual(bareImports('import"react";'), ['react'])
|
||||||
|
assert.deepStrictEqual(bareImports('import{useState}from"react";'), ['react'])
|
||||||
|
assert.deepStrictEqual(bareImports('const m=await import("react-dom/client")'), ['react-dom/client'])
|
||||||
|
// A relative specifier is a split chunk, not a shared dependency: not our concern.
|
||||||
|
assert.deepStrictEqual(bareImports('import"./other.js";'), [])
|
||||||
|
|
||||||
|
// The case that proves the mask tracks escapes: a quote escaped INSIDE a
|
||||||
|
// string must not end it early and leave the tail looking like code.
|
||||||
|
assert.deepStrictEqual(bareImports('const s="he said \\"import\\" loudly";'), [])
|
||||||
|
})
|
||||||
141
client/test/feed.test.js
Normal file
141
client/test/feed.test.js
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
// ── The feed's sentences ──────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// `lib/feed.js` is the one part of the client half with real branching in it, and
|
||||||
|
// it is pure on purpose so that a DOM-less runner can ask all of it. Everything
|
||||||
|
// here is a claim about what a reader sees for a given frame — which is exactly
|
||||||
|
// the kind of thing that rots silently, because a wrong killfeed line is still a
|
||||||
|
// killfeed line.
|
||||||
|
//
|
||||||
|
// The fixtures are the frames the bridge plugin actually emits (its
|
||||||
|
// `DescribeAttacker`, and PROTOCOL.md §8.4), not invented shapes.
|
||||||
|
|
||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { createRequire } from 'node:module'
|
||||||
|
|
||||||
|
import { describe, FEED_KINDS, FILTERS, kindsFor } from '../src/lib/feed.js'
|
||||||
|
|
||||||
|
const row = (kind, frame = {}) => ({ id: 1, kind, t: Date.now(), wipeId: 'w1', steamId: '7656', frame })
|
||||||
|
|
||||||
|
test('a player kill names the killer and the victim, in that order', () => {
|
||||||
|
const line = describe(row('player.death', {
|
||||||
|
name: 'Bob',
|
||||||
|
attackerType: 'player',
|
||||||
|
attackerName: 'Alice',
|
||||||
|
weapon: 'rifle.ak',
|
||||||
|
distance: 42.4,
|
||||||
|
grid: 'H7',
|
||||||
|
}))
|
||||||
|
|
||||||
|
assert.equal(line.tone, 'kill')
|
||||||
|
assert.equal(line.actor, 'Alice')
|
||||||
|
assert.equal(line.verb, 'killed')
|
||||||
|
assert.equal(line.subject, 'Bob')
|
||||||
|
assert.match(line.detail, /rifle ak/)
|
||||||
|
assert.match(line.detail, /42m/)
|
||||||
|
assert.match(line.detail, /H7/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the four attacker types are four different sentences', () => {
|
||||||
|
// The plugin distinguishes them precisely so a reader does not have to guess
|
||||||
|
// from an absent field, and collapsing any two loses something: a fall reported
|
||||||
|
// as a kill by nobody is the failure this prevents.
|
||||||
|
const victim = { name: 'Bob' }
|
||||||
|
|
||||||
|
const npc = describe(row('player.death', { ...victim, attackerType: 'npc', attackerName: 'scientistnpc_full_any' }))
|
||||||
|
assert.equal(npc.actor, 'scientistnpc full any')
|
||||||
|
assert.equal(npc.subject, 'Bob')
|
||||||
|
|
||||||
|
const self = describe(row('player.death', { ...victim, attackerType: 'self' }))
|
||||||
|
assert.equal(self.actor, 'Bob')
|
||||||
|
assert.equal(self.subject, null)
|
||||||
|
assert.match(self.verb, /own hand/)
|
||||||
|
|
||||||
|
const environment = describe(row('player.death', { ...victim, attackerType: 'environment' }))
|
||||||
|
assert.equal(environment.actor, 'Bob')
|
||||||
|
assert.equal(environment.verb, 'died')
|
||||||
|
assert.equal(environment.subject, null)
|
||||||
|
|
||||||
|
// `HitInfo` is legitimately null on the environment path, so a death frame with
|
||||||
|
// NO attacker type at all is that case — not a missing field to render around.
|
||||||
|
const bare = describe(row('player.death', victim))
|
||||||
|
assert.equal(bare.verb, 'died')
|
||||||
|
assert.equal(bare.subject, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a sleeping victim is said to have been sleeping', () => {
|
||||||
|
const line = describe(row('player.death', { name: 'Bob', attackerType: 'player', attackerName: 'Alice', sleeping: true }))
|
||||||
|
assert.match(line.detail, /while sleeping/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a disconnect with no session length says nothing about one', () => {
|
||||||
|
// The plugin OMITS `sessionSec` for a player who was already on when it loaded:
|
||||||
|
// an unknown session is not a session of no length. A line reading "after 0s"
|
||||||
|
// would be a lie this module invented.
|
||||||
|
const unknown = describe(row('player.disconnected', { name: 'Bob', reason: 'Quit' }))
|
||||||
|
assert.equal(unknown.detail, 'Quit')
|
||||||
|
|
||||||
|
const known = describe(row('player.disconnected', { name: 'Bob', reason: 'Quit', sessionSec: 3720 }))
|
||||||
|
assert.equal(known.detail, 'Quit · after 1h 2m')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a chat line carries the message as text, never as markup', () => {
|
||||||
|
// The message is the one field on this wire whose bytes a player chooses. It
|
||||||
|
// comes back as a STRING and is rendered as a React child, which escapes it;
|
||||||
|
// this test is here so that a later "render the message with formatting" idea
|
||||||
|
// has to delete an explicit assertion rather than quietly change behaviour.
|
||||||
|
const line = describe(row('player.chat', { name: 'Bob', message: '<img src=x onerror=alert(1)>', channel: 'Global' }))
|
||||||
|
assert.equal(line.verb, '<img src=x onerror=alert(1)>')
|
||||||
|
assert.equal(typeof line.verb, 'string')
|
||||||
|
// Global is the default channel and saying so on every line is noise; Team is
|
||||||
|
// information.
|
||||||
|
assert.equal(line.detail, '')
|
||||||
|
assert.equal(describe(row('player.chat', { name: 'B', message: 'hi', channel: 'Team' })).detail, 'Team')
|
||||||
|
|
||||||
|
// A chat row is the one line where the actor is a speaker rather than a
|
||||||
|
// subject, and "Brannock see you in september" is not a sentence anybody
|
||||||
|
// writes. The colon is presentation, so it lives here and not inside the text
|
||||||
|
// the player typed.
|
||||||
|
assert.equal(line.join, ': ')
|
||||||
|
assert.equal(describe(row('player.connected', { name: 'B' })).join, undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an unknown kind renders as itself rather than vanishing', () => {
|
||||||
|
// A later protocol adds kinds, and a module may be older than the game host it
|
||||||
|
// is reading. The server's allowlist has already decided the row may be seen;
|
||||||
|
// dropping it here would make the page quietly say less than the truth.
|
||||||
|
const line = describe(row('player.teleported', { name: 'Bob' }))
|
||||||
|
assert.equal(line.verb, 'player.teleported')
|
||||||
|
assert.equal(line.tone, 'other')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the feed never asks for the aggregate kind', () => {
|
||||||
|
// `player.tally` is public and is flushed once a minute per active player
|
||||||
|
// (§8.6). A feed that included it would be mostly wood counts; it is the
|
||||||
|
// leaderboard's input, and that is where it shows up.
|
||||||
|
assert.ok(!FEED_KINDS.includes('player.tally'))
|
||||||
|
for (const filter of FILTERS) {
|
||||||
|
for (const kind of filter.kinds) {
|
||||||
|
assert.ok(FEED_KINDS.includes(kind), `filter "${filter.id}" asks for ${kind}, which the feed does not carry`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('every kind the feed asks for is one the public route will serve', () => {
|
||||||
|
// Held against the module's own allowlist rather than against a copy of it: a
|
||||||
|
// kind this file asked for and `server/catalogue.js` refuses is a filter that
|
||||||
|
// silently returns nothing, which reads as a quiet server.
|
||||||
|
//
|
||||||
|
// A CommonJS file from the server half, read by an ESM test through
|
||||||
|
// `createRequire`. Crossing the two halves is fine HERE and nowhere else:
|
||||||
|
// `test/` is not shipped, and `scripts/checkImports.js` governs what is.
|
||||||
|
const catalogue = createRequire(import.meta.url)('../../server/catalogue.js')
|
||||||
|
for (const kind of FEED_KINDS) {
|
||||||
|
assert.ok(catalogue.PUBLIC_KINDS.includes(kind), `the feed asks for ${kind}, which is not public`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an unknown filter falls back to everything rather than to nothing', () => {
|
||||||
|
assert.deepEqual(kindsFor('nonsense'), FEED_KINDS)
|
||||||
|
assert.deepEqual(kindsFor(undefined), FEED_KINDS)
|
||||||
|
})
|
||||||
96
client/test/format.test.js
Normal file
96
client/test/format.test.js
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
// ── Formatting ────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Small functions, and the tests are small too — but three of them guard claims
|
||||||
|
// that would otherwise be made by a page that looks fine: an unknown duration
|
||||||
|
// rendered as zero, a timestamp in the wrong unit, and "in 0 seconds".
|
||||||
|
//
|
||||||
|
// Locale-dependent output is asserted loosely on purpose. `Intl` formats to the
|
||||||
|
// RUNNER's locale, and a test pinned to "3 minutes ago" would be a test that
|
||||||
|
// fails on a machine set to French while the page it describes is correct.
|
||||||
|
|
||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
|
||||||
|
import { ago, clock, count, day, duration, prefab, shortId } from '../src/lib/format.js'
|
||||||
|
|
||||||
|
const NOW = Date.parse('2026-09-16T12:00:00Z')
|
||||||
|
|
||||||
|
test('a relative time picks the unit that fits', () => {
|
||||||
|
assert.match(ago(NOW - 3 * 60_000, NOW), /3/)
|
||||||
|
assert.match(ago(NOW - 5 * 3600_000, NOW), /5/)
|
||||||
|
assert.match(ago(NOW - 3 * 86400_000, NOW), /3/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('"just now" rather than "in 0 seconds"', () => {
|
||||||
|
// What `numeric: 'auto'` produces under a minute is not what anybody means,
|
||||||
|
// and a feed row a few seconds old is the commonest row on the page.
|
||||||
|
assert.equal(ago(NOW, NOW), 'just now')
|
||||||
|
assert.equal(ago(NOW - 10_000, NOW), 'just now')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('both time shapes this module serves are accepted', () => {
|
||||||
|
// `updatedAt` is an ISO string the model produced; an event's `t` is the
|
||||||
|
// millisecond stamp the plugin put on the frame. A helper that took only one
|
||||||
|
// would be a helper every caller has to remember the type for.
|
||||||
|
assert.equal(ago('2026-09-16T11:57:00.000Z', NOW), ago(NOW - 3 * 60_000, NOW))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a missing time is "never", not the epoch', () => {
|
||||||
|
assert.equal(ago(null), 'never')
|
||||||
|
assert.equal(ago(undefined), 'never')
|
||||||
|
assert.equal(ago(''), 'never')
|
||||||
|
assert.equal(day(null), 'unknown')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an unknown duration is a dash, and a short one keeps its seconds', () => {
|
||||||
|
// The distinction the plugin makes and this must not lose: `sessionSec` is
|
||||||
|
// ABSENT for a player who was already on when it loaded, so zero and unknown
|
||||||
|
// arrive at the same function and must not render the same way.
|
||||||
|
assert.equal(duration(null), '—')
|
||||||
|
assert.equal(duration(0), '—')
|
||||||
|
assert.equal(duration(40), '40s')
|
||||||
|
assert.equal(duration(90), '2m')
|
||||||
|
assert.equal(duration(3720), '1h 2m')
|
||||||
|
assert.equal(duration(7200), '2h')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a prefab reads as words, without a lookup table', () => {
|
||||||
|
assert.equal(prefab('rifle.ak'), 'rifle ak')
|
||||||
|
assert.equal(prefab('scientistnpc_full_any'), 'scientistnpc full any')
|
||||||
|
assert.equal(prefab(null), '')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a steam id is shortened without pretending to be a name', () => {
|
||||||
|
assert.equal(shortId('76561198000000001'), '…000001')
|
||||||
|
assert.equal(shortId(''), '')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a count that is not a number is zero, never NaN on the page', () => {
|
||||||
|
assert.equal(count(undefined), '0')
|
||||||
|
assert.equal(count(null), '0')
|
||||||
|
})
|
||||||
|
|
||||||
|
test("a feed row from another day carries its date, not just a time", () => {
|
||||||
|
// Found by the page walk: with the feed filtered to the previous wipe, three
|
||||||
|
// events from six weeks ago rendered as `02:03 PM` and read as this afternoon.
|
||||||
|
// Today's rows stay bare, because a killfeed of today's fights does not want
|
||||||
|
// the date on every line.
|
||||||
|
// Asserted against `Intl` rather than against a literal: a 12-hour locale puts
|
||||||
|
// letters in a bare time ("05:30 AM"), so "has letters in it" is not the test —
|
||||||
|
// "is exactly the time, and nothing else" is.
|
||||||
|
const time = (at) => new Date(at).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
|
||||||
|
|
||||||
|
const todayAt = NOW - 90 * 60_000
|
||||||
|
assert.equal(clock(todayAt, NOW), time(todayAt))
|
||||||
|
|
||||||
|
const olderAt = NOW - 46 * 86400_000
|
||||||
|
assert.ok(clock(olderAt, NOW).endsWith(time(olderAt)))
|
||||||
|
assert.ok(clock(olderAt, NOW).length > time(olderAt).length, 'an older row carries no date')
|
||||||
|
|
||||||
|
// Yesterday counts as another day even when it is only a few hours back — the
|
||||||
|
// boundary is the calendar, not a duration, because that is what a reader
|
||||||
|
// means by "what time was that".
|
||||||
|
const lateLastNight = Date.parse('2026-09-15T23:50:00')
|
||||||
|
const earlyToday = Date.parse('2026-09-16T00:20:00')
|
||||||
|
assert.ok(clock(lateLastNight, earlyToday).length > time(lateLastNight).length)
|
||||||
|
})
|
||||||
269
client/test/registration.test.js
Normal file
269
client/test/registration.test.js
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
// ── What the chunk registers, checked without a browser ───────────────────
|
||||||
|
//
|
||||||
|
// `build.test.js` says the honest thing about this half: its real failures are
|
||||||
|
// timing and resolution, and a DOM-less runner cannot see either. MODULE_API.md
|
||||||
|
// §7.7's browser smoke is what proves the client half works, and nothing here
|
||||||
|
// replaces it.
|
||||||
|
//
|
||||||
|
// What a test CAN do is read back what the chunk asked for. Registration is the
|
||||||
|
// one thing the chunk does at evaluation time, and it does it through an object
|
||||||
|
// core hands it — so: stand up a fake `window.__rg` with a recording registry and
|
||||||
|
// the real React behind it, import the BUILT artifact, and inspect the result. No
|
||||||
|
// DOM is needed because nothing renders; `<WorldStatus />` is `jsx(WorldStatus)`,
|
||||||
|
// an object, and the route table is full of them by design.
|
||||||
|
//
|
||||||
|
// It catches a page that silently stops being routed, a nav row whose `to` drifts
|
||||||
|
// from its route's path, and the whole registration surface disappearing because
|
||||||
|
// something threw halfway down entry.jsx.
|
||||||
|
//
|
||||||
|
// **It runs against `dist/entry.js`, so build before you test.** The skip below
|
||||||
|
// is deliberate — `npm test` has to be runnable before `npm run build` — which
|
||||||
|
// means a CI job that tests without building is a job asking nothing at all. Ours
|
||||||
|
// builds first, on purpose.
|
||||||
|
|
||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
import * as react from 'react'
|
||||||
|
import * as jsxRuntime from 'react/jsx-runtime'
|
||||||
|
import * as router from 'react-router-dom'
|
||||||
|
|
||||||
|
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const CHUNK = path.resolve(HERE, '..', 'dist', 'entry.js')
|
||||||
|
const manifest = JSON.parse(fs.readFileSync(path.resolve(HERE, '..', '..', 'module.json'), 'utf8'))
|
||||||
|
|
||||||
|
// Core's contribution catalogue, as of MODULE_API 1.6.0 (§3.7a). Written down
|
||||||
|
// rather than imported: this suite runs against the BUILT chunk with no core in
|
||||||
|
// the process, so it is a claim about core that has to be re-read when core's list
|
||||||
|
// changes — the same trade the rest of this fake makes.
|
||||||
|
const CORE_CONTRIBUTIONS = ['team.activity', 'team.forum', 'team.notify']
|
||||||
|
|
||||||
|
// A component, as far as the registry cares. The kit's real members are core's;
|
||||||
|
// nothing renders here, so a named stub is enough to be imported and passed on.
|
||||||
|
const stub = (name) => Object.assign(() => null, { displayName: name })
|
||||||
|
|
||||||
|
function fakeRg() {
|
||||||
|
const routes = { public: [], admin: [], player: [] }
|
||||||
|
const nav = { public: [], admin: [], player: [] }
|
||||||
|
const providers = new Map()
|
||||||
|
const extensions = new Map()
|
||||||
|
const declaredSlots = []
|
||||||
|
return {
|
||||||
|
version: manifest.coreApi.replace(/^\D+/, ''),
|
||||||
|
react,
|
||||||
|
jsxRuntime,
|
||||||
|
router,
|
||||||
|
// `react-dom/client` is imported for the identity check in core.js and never
|
||||||
|
// called — `createRoot` in a DOM-less process would throw. The shim reads
|
||||||
|
// this object, so the check compares against whatever is here.
|
||||||
|
reactDom: { createRoot: () => { throw new Error('not in a browser') } },
|
||||||
|
ui: Object.fromEntries(
|
||||||
|
['PublicLayout', 'PageHeader', 'Loading', 'ErrorState', 'EmptyState', 'useAsync', 'useAuth', 'useSite', 'Slot']
|
||||||
|
.map((n) => [n, stub(n)]),
|
||||||
|
),
|
||||||
|
api: { request: async () => ({}), ApiError: Error, BASE: '/api/v1' },
|
||||||
|
registry: {
|
||||||
|
// Core's own prefixing, character for character (client/src/modules/registry.js):
|
||||||
|
// the leading separators of the module's path are stripped and so are the
|
||||||
|
// TRAILING ones, which is what lets a module register `path: ''` and own its
|
||||||
|
// namespace root — `/rust` rather than `/rust/`.
|
||||||
|
//
|
||||||
|
// This fake did the obvious `${id}/${path}` until phase 4, and the day a
|
||||||
|
// module registered an index route it produced `rust/` while a real core
|
||||||
|
// produced `rust`. The suite then failed the nav check for a link that works
|
||||||
|
// perfectly in a browser. A fake that is nearly core is worse than one that
|
||||||
|
// is obviously not: it fails on the truth.
|
||||||
|
registerRoutes(id, byArea) {
|
||||||
|
for (const [area, list] of Object.entries(byArea || {})) {
|
||||||
|
for (const r of list || []) {
|
||||||
|
const path = `${id}/${String(r.path || '').replace(/^\/+/, '')}`.replace(/\/+$/, '')
|
||||||
|
routes[area].push({ ...r, path, moduleId: id })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
registerNav(id, { area, items }) {
|
||||||
|
for (const item of items || []) nav[area].push({ ...item, moduleId: id })
|
||||||
|
},
|
||||||
|
registerFeatureProvider(id, namespace, hook) { providers.set(namespace, { id, hook }) },
|
||||||
|
registerExtension(id, slot, Component) {
|
||||||
|
if (extensions.has(slot)) throw new Error(`slot "${slot}" already filled`)
|
||||||
|
extensions.set(slot, { id, Component })
|
||||||
|
},
|
||||||
|
// The INVERTED direction (1.6.0): the module declares, core fills. Core
|
||||||
|
// enforces the namespace AND the contribution name at this call, which is why
|
||||||
|
// the fake does too — either one core would reject is a slot that renders
|
||||||
|
// nothing on a real install and everything in a suite that shrugged.
|
||||||
|
declareModuleSlot(id, name, options = {}) {
|
||||||
|
if (!name.startsWith(`${id}.`)) throw new Error(`"${name}" is not namespaced under "${id}"`)
|
||||||
|
const wants = options.core ?? null
|
||||||
|
if (wants !== null && !CORE_CONTRIBUTIONS.includes(wants)) {
|
||||||
|
throw new Error(`"${name}" asks for core contribution "${wants}", which core does not offer`)
|
||||||
|
}
|
||||||
|
declaredSlots.push({ id, name, wants })
|
||||||
|
},
|
||||||
|
routesFor: (area) => routes[area],
|
||||||
|
navFor: (area) => nav[area],
|
||||||
|
},
|
||||||
|
_read: () => ({ routes, nav, providers, extensions, declaredSlots }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loaded once: an ES module is evaluated a single time per process however many
|
||||||
|
// times it is imported, so every test below reads the same registration pass —
|
||||||
|
// which is also how it behaves in a browser.
|
||||||
|
let registered = null
|
||||||
|
let skip = false
|
||||||
|
|
||||||
|
if (!fs.existsSync(CHUNK)) {
|
||||||
|
skip = true
|
||||||
|
} else {
|
||||||
|
const rg = fakeRg()
|
||||||
|
globalThis.window = { __rg: rg }
|
||||||
|
await import(`${new URL(`file://${CHUNK.split(path.sep).join('/')}`)}`)
|
||||||
|
registered = rg._read()
|
||||||
|
}
|
||||||
|
|
||||||
|
const it = (name, fn) => test(name, { skip: skip && 'no dist/entry.js — run npm run build' }, fn)
|
||||||
|
|
||||||
|
it('registers at least one route, namespaced under the module id', () => {
|
||||||
|
const all = Object.values(registered.routes).flat()
|
||||||
|
assert.ok(all.length > 0, 'the chunk registered no routes at all')
|
||||||
|
for (const [area, list] of Object.entries(registered.routes)) {
|
||||||
|
for (const r of list) {
|
||||||
|
// Either the namespace root itself (a module's index route, `rust`) or
|
||||||
|
// something under it (`rust/servers/:id`). `startsWith('rust/')` alone
|
||||||
|
// would reject the root — and `startsWith('rust')` alone would accept a
|
||||||
|
// hypothetical `rustling`, which is why this is spelled out.
|
||||||
|
const under = r.path === manifest.id || r.path.startsWith(`${manifest.id}/`)
|
||||||
|
assert.ok(under, `${area} route "${r.path}" is not under the namespace`)
|
||||||
|
assert.ok(r.element, `${area} route "${r.path}" has no element`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('every route path is distinct within its area', () => {
|
||||||
|
// Two routes on one path is a page that can never be reached, and React
|
||||||
|
// renders the first one without complaint.
|
||||||
|
for (const [area, list] of Object.entries(registered.routes)) {
|
||||||
|
const paths = list.map((r) => r.path)
|
||||||
|
assert.equal(new Set(paths).size, paths.length, `duplicate path in ${area}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('every nav row points at a route this module actually registered', () => {
|
||||||
|
// The agreement that matters, and the one that rots quietly: a row survives a
|
||||||
|
// route rename and becomes a link to core's catch-all redirect. Nav rows carry
|
||||||
|
// the FULL rendered path (`/rust/servers`); routes carry the namespaced
|
||||||
|
// one (`rust/servers`). Reconciling the two is the whole test.
|
||||||
|
const rendered = {
|
||||||
|
public: (p) => `/${p}`,
|
||||||
|
admin: (p) => `/admin/${p}`,
|
||||||
|
player: (p) => `/player/${p}`,
|
||||||
|
}
|
||||||
|
for (const [area, rows] of Object.entries(registered.nav)) {
|
||||||
|
const reachable = new Set(registered.routes[area].map((r) => rendered[area](r.path)))
|
||||||
|
for (const row of rows) {
|
||||||
|
assert.ok(reachable.has(row.to), `${area} nav row "${row.label}" links to ${row.to}, which no route serves`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('every admin and player nav row carries an icon', () => {
|
||||||
|
// Both of those navs draw a glyph on every core row, so a row without one reads
|
||||||
|
// as breakage rather than as a design — and core's player portal used to render
|
||||||
|
// `<n.icon />` unguarded, which blanked the entire portal with React error #130
|
||||||
|
// the first time a module registered a row without one. Core guards it now; a
|
||||||
|
// missing icon there is still a visible defect and this is the cheap place to
|
||||||
|
// catch it. The PUBLIC header is text buttons and is deliberately excluded.
|
||||||
|
for (const area of ['admin', 'player']) {
|
||||||
|
for (const row of registered.nav[area]) {
|
||||||
|
assert.equal(typeof row.icon, 'function', `${area} nav row "${row.label}" has no icon`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a nav row that gates on a feature has a provider to resolve it', () => {
|
||||||
|
// Resolution is by the REGISTERING module (§3.3), and every unknown fails OPEN.
|
||||||
|
// So a row carrying a `feature` from a module that registered no provider is a
|
||||||
|
// row that always shows — which re-advertises a surface an operator hid.
|
||||||
|
const gated = Object.values(registered.nav).flat().filter((r) => r.feature)
|
||||||
|
if (gated.length === 0) return
|
||||||
|
assert.ok(registered.providers.size > 0, 'rows carry feature gates but no provider was registered')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('the footer slot core declares is filled, and by a component', () => {
|
||||||
|
// R13's first slot, and the half that lives in the CHUNK: `site.footer.status`
|
||||||
|
// is a CLIENT slot, so it cannot be named in `module.json`'s `extensions` —
|
||||||
|
// that array is validated against the SERVER registry and naming a client slot
|
||||||
|
// there fails the load outright. Nothing else holds this registration, and an
|
||||||
|
// extension that stopped being registered is invisible: an unfilled slot
|
||||||
|
// renders nothing, exactly as an uninstalled module does.
|
||||||
|
const footer = registered.extensions.get('site.footer.status')
|
||||||
|
assert.ok(footer, 'nothing fills site.footer.status')
|
||||||
|
assert.equal(footer.id, manifest.id)
|
||||||
|
assert.equal(typeof footer.Component, 'function')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('every slot module.json declares is one the chunk fills', () => {
|
||||||
|
// `module.json` declares SERVER slots, and the loader validates those before
|
||||||
|
// the chunk is ever served. Client slots cannot be declared there — the server
|
||||||
|
// knows nothing about them — so this is the one place the two halves meet.
|
||||||
|
for (const slot of manifest.extensions || []) {
|
||||||
|
assert.ok(registered.extensions.has(slot), `module.json declares "${slot}" and the chunk does not fill it`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** The source of every page under `src/routes`, so a slot can be looked for in all of them. */
|
||||||
|
function pageSources(dir = path.resolve(HERE, '..', 'src', 'routes'), out = []) {
|
||||||
|
if (!fs.existsSync(dir)) return out
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const full = path.join(dir, entry.name)
|
||||||
|
if (entry.isDirectory()) pageSources(full, out)
|
||||||
|
else if (/\.jsx?$/.test(entry.name)) out.push(fs.readFileSync(full, 'utf8'))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
it('every declared slot is namespaced under this module and rendered by a page', () => {
|
||||||
|
// Two halves that nothing else holds together. The namespace is core's rule and
|
||||||
|
// the fake enforces it at the call; what a test has to check is the OTHER end —
|
||||||
|
// a slot declared and never rendered is a promise to core that no page keeps,
|
||||||
|
// and it fails silently, because an unrendered slot looks exactly like an
|
||||||
|
// unfilled one.
|
||||||
|
// Every page, not one named file. The kit's template reads its single slot-
|
||||||
|
// bearing page by name, which works until a module either renames that page or
|
||||||
|
// — as this one does in phase 1 — declares no slots at all: the `readFileSync`
|
||||||
|
// runs before the loop that would have been empty, and the suite dies on a
|
||||||
|
// missing file rather than passing with nothing to check.
|
||||||
|
const pages = pageSources().join('\n')
|
||||||
|
for (const { id, name } of registered.declaredSlots) {
|
||||||
|
assert.equal(id, manifest.id)
|
||||||
|
assert.ok(name.startsWith(`${manifest.id}.`), `slot "${name}" is not under the module namespace`)
|
||||||
|
assert.ok(pages.includes(`name="${name}"`), `slot "${name}" is declared and never rendered`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('every declared slot names a core contribution core actually offers', () => {
|
||||||
|
// The fake throws on an unknown one, exactly as core does, so this asserts the
|
||||||
|
// other half: that the slots asked for something at all. A slot with no `core`
|
||||||
|
// is legal and stays empty — which is right for a place you fill yourself and
|
||||||
|
// wrong for one you are waiting on core for, and only you know which it is.
|
||||||
|
for (const { name, wants } of registered.declaredSlots) {
|
||||||
|
assert.ok(wants, `slot "${name}" asks for no core contribution, so nothing will ever fill it`)
|
||||||
|
assert.ok(CORE_CONTRIBUTIONS.includes(wants))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('registers under exactly one module id, matching the manifest', () => {
|
||||||
|
const owners = new Set([
|
||||||
|
...Object.values(registered.routes).flat().map((r) => r.moduleId),
|
||||||
|
...Object.values(registered.nav).flat().map((r) => r.moduleId),
|
||||||
|
...[...registered.extensions.values()].map((e) => e.id),
|
||||||
|
...[...registered.providers.values()].map((p) => p.id),
|
||||||
|
...registered.declaredSlots.map((s) => s.id),
|
||||||
|
])
|
||||||
|
assert.deepEqual([...owners], [manifest.id])
|
||||||
|
})
|
||||||
137
client/vite.config.js
Normal file
137
client/vite.config.js
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
// ── The client half's library build ────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Produces `dist/entry.js`: one prebuilt ES module that core injects as a
|
||||||
|
// same-origin `<script type="module" src>` before `</body>`. The operator never
|
||||||
|
// builds anything (MODULE_SYSTEM.md §1.14), so this config is not a developer
|
||||||
|
// convenience — it is how the artifact that ships is made, and CI runs it.
|
||||||
|
//
|
||||||
|
// The normative contract is MODULE_API.md §3.6. Three mechanical details in here
|
||||||
|
// were each found the hard way and are worth reading before changing anything.
|
||||||
|
//
|
||||||
|
// **1. `resolve.alias` uses the ARRAY form with anchored regexes.** Vite's object
|
||||||
|
// form does PREFIX matching, so a `react` key also rewrites `react/jsx-runtime`
|
||||||
|
// — silently, to the wrong shim, and the chunk then fails at its first element
|
||||||
|
// with a message about `jsx` not being a function. `^react$` and
|
||||||
|
// `^react/jsx-runtime$` cannot collide.
|
||||||
|
//
|
||||||
|
// **2. The aliases replace `external`; they do not accompany it.** §3.6 shows
|
||||||
|
// both, and they do not compose: Rollup asks `external` BEFORE Vite's alias
|
||||||
|
// resolver runs, so a specifier listed there is marked external and never
|
||||||
|
// aliased. The chunk then ships bare `import 'react'` specifiers, which the
|
||||||
|
// browser cannot resolve without an import map — and core's `script-src 'self'`
|
||||||
|
// forbids the inline script an import map has to be. (`output.globals` would
|
||||||
|
// have covered iife/umd and does nothing for an ES module.) The first real module
|
||||||
|
// shipped with both, built cleanly, and emitted exactly that chunk;
|
||||||
|
// `scripts/checkExternals.js` is what caught it. So: alias only, and nothing in
|
||||||
|
// `external`.
|
||||||
|
//
|
||||||
|
// **3. What `external` was there to guard is guarded by `assertSharedNotBundled`
|
||||||
|
// below.** The risk it was covering is real — an alias that misses means a
|
||||||
|
// second React welded into the chunk, which loads fine and then throws about an
|
||||||
|
// invalid hook call somewhere unrelated. A resolution-time assertion catches
|
||||||
|
// that precisely, at build time, instead of by looking for fingerprints in
|
||||||
|
// minified output afterwards.
|
||||||
|
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const shim = (name) => fileURLToPath(new URL(`./src/shim/${name}.js`, import.meta.url))
|
||||||
|
|
||||||
|
// The shared dependencies, in one place: what a module must never bundle, and
|
||||||
|
// the shim it is aliased to instead. Adding to this list means adding to
|
||||||
|
// `window.__rg` in core, which is a MODULE_API minor bump — not a decision this
|
||||||
|
// file can make on its own.
|
||||||
|
export const SHARED = [
|
||||||
|
{ specifier: 'react', shim: 'react' },
|
||||||
|
{ specifier: 'react/jsx-runtime', shim: 'jsx-runtime' },
|
||||||
|
// A production `vite build` emits the non-dev runtime, but the plugin picks
|
||||||
|
// per mode and a `--mode development` build would reach for this one. Aliased
|
||||||
|
// rather than left to chance: the shim re-exports `jsxDEV` too.
|
||||||
|
{ specifier: 'react/jsx-dev-runtime', shim: 'jsx-runtime' },
|
||||||
|
{ specifier: 'react-dom', shim: 'react-dom' },
|
||||||
|
{ specifier: 'react-dom/client', shim: 'react-dom' },
|
||||||
|
{ specifier: 'react-router-dom', shim: 'react-router-dom' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// The packages whose real source must never end up in the chunk.
|
||||||
|
//
|
||||||
|
// Stated independently of SHARED, and that is the whole point — an earlier
|
||||||
|
// version derived this from the alias list "so the two cannot disagree", which
|
||||||
|
// meant deleting an alias also deleted the guard against the thing that alias
|
||||||
|
// prevented. The guard then reported nothing on a chunk with react-router welded
|
||||||
|
// into it. What may not be bundled is a fact about core's `window.__rg`, not a
|
||||||
|
// function of what this config happens to alias; `test/build.test.js` asserts
|
||||||
|
// every SHARED specifier is covered here, which is the direction the dependency
|
||||||
|
// belongs in.
|
||||||
|
//
|
||||||
|
// `react-router` and `@remix-run/router` are react-router-dom's own internals.
|
||||||
|
// They cannot appear while the alias holds — nothing resolves through to them —
|
||||||
|
// so naming them costs nothing and closes the case where a module imports one
|
||||||
|
// directly and gets a second navigation context in a page that otherwise works.
|
||||||
|
export const SHARED_PACKAGES = ['react', 'react-dom', 'react-router-dom', 'react-router', '@remix-run/router']
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fail the build if a shared dependency's real source is about to be bundled.
|
||||||
|
*
|
||||||
|
* This is the safety net, and it is a resolution-time one on purpose. The
|
||||||
|
* alternative — grepping the built chunk for a fingerprint — has to guess at
|
||||||
|
* strings that survive minification, and guesses at that are how a check ends up
|
||||||
|
* passing on a chunk that carries a second React. Here there is nothing to
|
||||||
|
* guess: if a module id resolved into `node_modules/react`, an alias missed, and
|
||||||
|
* the alias that missed is named in the error.
|
||||||
|
*
|
||||||
|
* It hooks `transform` rather than `load`, and that is not interchangeable:
|
||||||
|
* `load` is FIRST-WINS, so an earlier plugin returning the module's contents
|
||||||
|
* means this hook is never called for it. Written against `load` this guard sat
|
||||||
|
* in the build doing nothing, and a deliberately-broken alias produced a 24 kB
|
||||||
|
* chunk with react-router welded into it and a green build — which is the exact
|
||||||
|
* failure it exists to prevent. `transform` runs for every module, every time.
|
||||||
|
*/
|
||||||
|
function assertSharedNotBundled() {
|
||||||
|
return {
|
||||||
|
name: 'rust:assert-shared-not-bundled',
|
||||||
|
enforce: 'post',
|
||||||
|
transform(code, id) {
|
||||||
|
const normalised = id.split('\\').join('/')
|
||||||
|
const hit = SHARED_PACKAGES.find((pkg) => normalised.includes(`/node_modules/${pkg}/`))
|
||||||
|
if (hit) {
|
||||||
|
this.error(
|
||||||
|
`"${hit}" resolved into node_modules (${normalised}). It must be aliased to a shim that ` +
|
||||||
|
're-exports from window.__rg — there is exactly one React in the page and core owns it ' +
|
||||||
|
'(MODULE_API.md §3.2, §3.6). Check resolve.alias in vite.config.js.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), assertSharedNotBundled()],
|
||||||
|
resolve: {
|
||||||
|
alias: SHARED.map(({ specifier, shim: name }) => ({
|
||||||
|
find: new RegExp(`^${specifier.replace(/[/\\^$*+?.()|[\]{}]/g, '\\$&')}$`),
|
||||||
|
replacement: shim(name),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
lib: {
|
||||||
|
entry: fileURLToPath(new URL('./src/entry.jsx', import.meta.url)),
|
||||||
|
formats: ['es'],
|
||||||
|
// Unhashed, deliberately: `module.json` names this file, and a hashed name
|
||||||
|
// would have to be discovered at runtime. Core answers the cache question
|
||||||
|
// instead, serving it `no-cache` so a revalidation catches a new build
|
||||||
|
// (MODULE_API.md §3.1).
|
||||||
|
fileName: () => 'entry.js',
|
||||||
|
},
|
||||||
|
outDir: 'dist',
|
||||||
|
emptyOutDir: true,
|
||||||
|
// No inline bootstrap, for the same reason core disables it: an inline
|
||||||
|
// script is refused under `script-src 'self'`, and the failure is a chunk
|
||||||
|
// that never evaluates with a CSP report as the only clue.
|
||||||
|
modulePreload: { polyfill: false },
|
||||||
|
// `rollupOptions.external` is deliberately EMPTY — see note 2 at the top.
|
||||||
|
rollupOptions: { external: [] },
|
||||||
|
},
|
||||||
|
})
|
||||||
17
module.json
Normal file
17
module.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"id": "rust",
|
||||||
|
"name": "Rust",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"coreApi": "^1.10.0",
|
||||||
|
"server": "server/index.js",
|
||||||
|
"client": { "entry": "client/dist/entry.js" },
|
||||||
|
"schema": "server/db/schema.sql",
|
||||||
|
"purge": "server/db/purge.sql",
|
||||||
|
"mounts": {
|
||||||
|
"public": ["/rust"],
|
||||||
|
"admin": ["/rust"],
|
||||||
|
"player": ["/rust"]
|
||||||
|
},
|
||||||
|
"extensions": ["admin.users.detail"],
|
||||||
|
"capabilities": ["rust", "servers", "killfeed", "leaderboard", "presence", "wipes", "identity"]
|
||||||
|
}
|
||||||
155
routes.manifest.json
Normal file
155
routes.manifest.json
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
{
|
||||||
|
"$comment": "Generated inventory of the URLs module-rust serves - the module half of the freeze core keeps in server/routes.manifest.json. DERIVED as the difference between a core without this module and the same core with it, both at the pinned ref in ci/core-ref.json. Regenerate with the frozen-manifest job in .gitea/workflows/pr-checks.yml; see server/scripts/frozenManifest.js.",
|
||||||
|
"routes": [
|
||||||
|
{
|
||||||
|
"method": "DELETE",
|
||||||
|
"path": "/api/v1/admin/rust/permissions/grants/:id",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "DELETE",
|
||||||
|
"path": "/api/v1/admin/rust/permissions/groups/:name",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "DELETE",
|
||||||
|
"path": "/api/v1/admin/rust/permissions/groups/:name/members/:userId",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "DELETE",
|
||||||
|
"path": "/api/v1/admin/rust/servers/:id",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "DELETE",
|
||||||
|
"path": "/api/v1/admin/users/:id/rust/links/:steamId",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "DELETE",
|
||||||
|
"path": "/api/v1/admin/users/:id/rust/permissions/grants/:grantId",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "DELETE",
|
||||||
|
"path": "/api/v1/player/rust/links/:steamId",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/rust/permissions",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/rust/permissions/catalogue",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/rust/servers",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/users/:id/rust/links",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/users/:id/rust/permissions",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/player/rust/links",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/player/rust/servers",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/rust/servers",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/rust/servers/:id",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/rust/servers/:id/events",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/rust/servers/:id/leaderboard",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/rust/servers/:id/online",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/rust/servers/:id/wipes",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/rust/permissions/drift/:id/adopt",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/rust/permissions/drift/:id/revoke",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/rust/permissions/grants",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/rust/permissions/groups/:name/members",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/rust/permissions/sync",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/rust/servers/:id/test",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/users/:id/rust/permissions/grants",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/player/rust/link",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "PUT",
|
||||||
|
"path": "/api/v1/admin/rust/permissions/groups/:name",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "PUT",
|
||||||
|
"path": "/api/v1/admin/rust/servers/:id",
|
||||||
|
"tier": "public"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
244
server/boot.js
Normal file
244
server/boot.js
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
// ── The lifecycle hooks ───────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// `register()` may not touch the database (MODULE_API.md §2.2). This file is
|
||||||
|
// where everything it could not do goes.
|
||||||
|
//
|
||||||
|
// core schema → this module's schema fragment → onBoot(ctx) → the listener binds
|
||||||
|
//
|
||||||
|
// So by the time `onBoot` runs the tables exist, core's settings are seeded, and
|
||||||
|
// nothing is serving traffic yet.
|
||||||
|
//
|
||||||
|
// **`onBoot` has no timeout.** Shutdown races the process being killed; boot does
|
||||||
|
// not. A slow `onBoot` delays the listener, which is the promise above rather
|
||||||
|
// than a problem to be timed out.
|
||||||
|
//
|
||||||
|
// **If `onBoot` throws, the module is `startup_failed` and the site still comes
|
||||||
|
// up.** Its routes stay mounted but answer 503, because a module that failed to
|
||||||
|
// warm up serving half-initialised data is worse than one that says it is down.
|
||||||
|
// There is then NO `onShutdown` — being handed a half-built world to tear down is
|
||||||
|
// worse than not closing cleanly. Which is why the poll below catches everything:
|
||||||
|
// a sidecar that is not there yet is the ordinary state of a fresh install, and
|
||||||
|
// letting that fail the boot would make installing the module before installing
|
||||||
|
// the bridge impossible.
|
||||||
|
//
|
||||||
|
// ── Three timers, and they answer three different questions ───────────────
|
||||||
|
//
|
||||||
|
// refresh (30s) what is each server, and who is on it — the BOARDS
|
||||||
|
// ingest (5s) what has happened since we last looked — the CURSOR
|
||||||
|
// prune (1h) forgetting the detail we promised not to keep for ever
|
||||||
|
//
|
||||||
|
// The boards poll and the ingest are deliberately separate rather than one loop
|
||||||
|
// reading both. They fail differently and they matter differently: a board that
|
||||||
|
// is 30 seconds stale shows a player count slightly behind, and an ingest that
|
||||||
|
// is 30 seconds behind shows a killfeed that feels broken. Splitting them lets
|
||||||
|
// the cheap one run often and the expensive one run rarely, and it means a
|
||||||
|
// sidecar that answers one and not the other degrades in exactly one place.
|
||||||
|
//
|
||||||
|
// The poll was never a placeholder for a socket: a sidecar's store-backed reads
|
||||||
|
// are what answer while a game server is off, which is most of what this module
|
||||||
|
// renders. See `ingest.js` for why the live feed is a cursor and not a
|
||||||
|
// WebSocket.
|
||||||
|
|
||||||
|
const core = require('./core')
|
||||||
|
|
||||||
|
const db = require('./model/servers/servers.db')
|
||||||
|
const eventsDb = require('./model/events/events.db')
|
||||||
|
const ingest = require('./ingest')
|
||||||
|
const permSync = require('./permSync')
|
||||||
|
const servers = require('./model/servers/servers.model')
|
||||||
|
const sidecar = require('./sidecarClient')
|
||||||
|
|
||||||
|
const log = core.logger('boot')
|
||||||
|
|
||||||
|
let refreshTimer = null
|
||||||
|
let ingestTimer = null
|
||||||
|
let pruneTimer = null
|
||||||
|
|
||||||
|
const REFRESH_MS = 30 * 1000
|
||||||
|
const INGEST_MS = 5 * 1000
|
||||||
|
const PRUNE_MS = 60 * 60 * 1000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long this module keeps raw events.
|
||||||
|
*
|
||||||
|
* Longer than the sidecar's 14 days, because this is the richer store and the
|
||||||
|
* one a page reads — and because the sidecar lives on somebody's game host while
|
||||||
|
* this lives on the website's own database. What is NOT bounded by it is the
|
||||||
|
* record: `rust_player_wipe_stats` and `rust_gather_totals` are permanent, which
|
||||||
|
* is the whole of R12's "a wipe does not erase a player's history".
|
||||||
|
*/
|
||||||
|
const EVENT_RETENTION_DAYS = 30
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask every configured sidecar how its server is doing, and store what it said.
|
||||||
|
*
|
||||||
|
* **Every server is polled independently and one failure never stops the
|
||||||
|
* others.** `Promise.allSettled`, not `Promise.all`: six servers behind one
|
||||||
|
* unreachable host would otherwise mean the whole fleet stops updating because
|
||||||
|
* one of them does, and the site would report five healthy servers offline.
|
||||||
|
*/
|
||||||
|
async function refresh() {
|
||||||
|
let rows
|
||||||
|
try {
|
||||||
|
rows = await servers.listForPolling()
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('could not read the server list', { error: err.message })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.allSettled(rows.map(refreshOne))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshOne(server) {
|
||||||
|
try {
|
||||||
|
// One call for both boards. `/server` would answer the same question about
|
||||||
|
// the server itself, but presence would then be a second round trip to the
|
||||||
|
// same process for a fact it already had in hand.
|
||||||
|
const board = await sidecar.boards(server)
|
||||||
|
|
||||||
|
// Three outcomes, and collapsing any two of them loses something an operator
|
||||||
|
// needs:
|
||||||
|
//
|
||||||
|
// • the sidecar answered with a frame → the server has connected at least once
|
||||||
|
// • the sidecar answered 204 (`empty`) → the sidecar is up and the game never connected
|
||||||
|
// • the sidecar did not answer → the bridge is unreachable
|
||||||
|
//
|
||||||
|
// The middle case is the one that is easy to lose. It is a fresh install
|
||||||
|
// whose plugin is not loaded yet, and reporting it as unreachable sends the
|
||||||
|
// operator to look at the network instead of at the game server.
|
||||||
|
if (!board.ok) {
|
||||||
|
// `markUnreachable`, not `putState`: nothing answered, so the only new fact
|
||||||
|
// is that nothing answered. Writing the whole row from that one fact would
|
||||||
|
// blank the hostname, the map, the seed and the wipe — the last thing this
|
||||||
|
// server said, which is exactly what the pages exist to render while it is
|
||||||
|
// off.
|
||||||
|
await db.markUnreachable(server.id, false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const boards = (board.data && board.data.boards) || {}
|
||||||
|
const frame = boards['server.hello']
|
||||||
|
|
||||||
|
if (!frame) {
|
||||||
|
// The sidecar is up and has never heard from the game. Presence is emptied
|
||||||
|
// rather than left alone: a stale list of players on a server nobody can
|
||||||
|
// reach is worse than an empty one, because it looks current.
|
||||||
|
await db.markUnreachable(server.id, true)
|
||||||
|
await ingest.applyBoards(server.id, {})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await ingest.applyBoards(server.id, boards)
|
||||||
|
|
||||||
|
await db.putState({
|
||||||
|
serverId: server.id,
|
||||||
|
reachable: true,
|
||||||
|
// A stored `server.hello` means the game connected; whether it is connected
|
||||||
|
// NOW is a different question, and `/health` is what answers it. The board
|
||||||
|
// alone cannot say, which is why `online` is not simply `true` here — it is
|
||||||
|
// decided by freshness in the model, from `updated_at`.
|
||||||
|
online: true,
|
||||||
|
players: Number(frame.players) || 0,
|
||||||
|
maxPlayers: Number(frame.maxPlayers) || 0,
|
||||||
|
hostname: frame.hostname || null,
|
||||||
|
level: frame.level || null,
|
||||||
|
seed: frame.seed === undefined ? null : Number(frame.seed),
|
||||||
|
worldSize: frame.worldSize === undefined ? null : Number(frame.worldSize),
|
||||||
|
bootId: frame.bootId || null,
|
||||||
|
saveCreatedAt: frame.saveCreatedAt || null,
|
||||||
|
wipeId: frame.wipeId || null,
|
||||||
|
protocol: frame.protocol === undefined ? null : Number(frame.protocol),
|
||||||
|
raw: frame,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
// A failure here is one server's, and it must not reach `Promise.allSettled`
|
||||||
|
// as a rejection that hides which one. Log with the id and carry on.
|
||||||
|
log.warn('could not refresh a server', { server: server.id, error: err.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs once, after the schema and before the listener binds.
|
||||||
|
*
|
||||||
|
* Receives the same frozen `ctx` `register()` was given — not a second object
|
||||||
|
* built to look like it — so a module that only needs core at boot time can skip
|
||||||
|
* `core.init` entirely and use this argument.
|
||||||
|
*/
|
||||||
|
/** Runs the cursor for every configured server, independently. */
|
||||||
|
async function ingestAll() {
|
||||||
|
let rows
|
||||||
|
|
||||||
|
try {
|
||||||
|
rows = await servers.listForPolling()
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('could not read the server list', { error: err.message })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// `allSettled`, for the same reason the board poll uses it: six servers behind
|
||||||
|
// one unreachable host must not stop the other five being ingested.
|
||||||
|
await Promise.allSettled(rows.map((server) => ingest.ingestServer(server)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prune() {
|
||||||
|
try {
|
||||||
|
const gone = await eventsDb.pruneEvents(EVENT_RETENTION_DAYS)
|
||||||
|
if (gone > 0) log.info('pruned old events', { events: gone, days: EVENT_RETENTION_DAYS })
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('could not prune events', { error: err.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onBoot() {
|
||||||
|
await refresh()
|
||||||
|
// The permission mirror owns its own loop and its own cadence (see
|
||||||
|
// `permSync.js`). It is started rather than run here: a first pass would write
|
||||||
|
// to every configured game server before the website had finished booting, and
|
||||||
|
// nothing about R2 is urgent enough to delay a listener for.
|
||||||
|
permSync.start()
|
||||||
|
refreshTimer = setInterval(refresh, REFRESH_MS)
|
||||||
|
ingestTimer = setInterval(ingestAll, INGEST_MS)
|
||||||
|
pruneTimer = setInterval(prune, PRUNE_MS)
|
||||||
|
// Node keeps the process alive for a pending timer. Core's own intervals are
|
||||||
|
// unref'd for exactly this reason: a module that forgets turns `Ctrl-C` into a
|
||||||
|
// thirty-second wait, and on a host it turns a `systemctl stop` into a SIGKILL.
|
||||||
|
for (const timer of [refreshTimer, ingestTimer, pruneTimer]) {
|
||||||
|
if (timer && typeof timer.unref === 'function') timer.unref()
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info('booted', { refreshMs: REFRESH_MS, ingestMs: INGEST_MS, permSyncMs: permSync.TICK_MS })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs on SIGINT/SIGTERM, before core closes anything of its own.
|
||||||
|
*
|
||||||
|
* The database pool, the push dispatcher and the SSE fan-out are all still open,
|
||||||
|
* because flushing through them is the only thing this hook is for. There is a
|
||||||
|
* five-second budget per module, after which the hook is abandoned — abandoned
|
||||||
|
* rather than cancelled, since nothing can stop a promise that is still running.
|
||||||
|
*/
|
||||||
|
async function onShutdown() {
|
||||||
|
permSync.stop()
|
||||||
|
|
||||||
|
for (const timer of [refreshTimer, ingestTimer, pruneTimer]) {
|
||||||
|
if (timer) clearInterval(timer)
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshTimer = null
|
||||||
|
ingestTimer = null
|
||||||
|
pruneTimer = null
|
||||||
|
|
||||||
|
log.info('shut down')
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
onBoot,
|
||||||
|
onShutdown,
|
||||||
|
refresh,
|
||||||
|
refreshOne,
|
||||||
|
ingestAll,
|
||||||
|
prune,
|
||||||
|
REFRESH_MS,
|
||||||
|
INGEST_MS,
|
||||||
|
EVENT_RETENTION_DAYS,
|
||||||
|
}
|
||||||
129
server/catalogue.js
Normal file
129
server/catalogue.js
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
// ── What the bridge can say, and who may hear it ──────────────────────────
|
||||||
|
//
|
||||||
|
// One file, because these two questions have to be answered together or the
|
||||||
|
// second one rots: which frame kinds exist, and which of them a member of the
|
||||||
|
// public may see.
|
||||||
|
//
|
||||||
|
// ── The boundary ──────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Protocol 2's catalogue includes frames carrying **IP addresses** (a login
|
||||||
|
// attempt, an approval, a ban) and **one player's complaint about another** (a
|
||||||
|
// report), and one — a destroyed structure — that names where somebody lives.
|
||||||
|
// They are stored, because an operator chasing ban evasion needs them and
|
||||||
|
// because the sidecar persists what it is told. They must never reach a public
|
||||||
|
// page.
|
||||||
|
//
|
||||||
|
// **The boundary is enforced HERE, on the side that serves, and not on the wire.**
|
||||||
|
// The plugin could have stamped a `class` on every frame and saved this file the
|
||||||
|
// trouble; it deliberately does not (PROTOCOL.md §8.5). A boundary declared by
|
||||||
|
// the sender is a boundary a compromised — or merely out-of-date — game host can
|
||||||
|
// widen. Core's own shard fan-out works the same way: a public stream with an
|
||||||
|
// allowlist of kinds, and an admin stream that adds the rest.
|
||||||
|
//
|
||||||
|
// ── Default deny, and why it is not paranoia ──────────────────────────────
|
||||||
|
//
|
||||||
|
// `isPublic` answers `false` for a kind it has never heard of. That matters
|
||||||
|
// because of the shape of the mistake it prevents: the next protocol version
|
||||||
|
// adds a kind, this module ingests it happily (`rust_events` stores what it is
|
||||||
|
// given), and a page that filtered by a DENY list would publish it the day it
|
||||||
|
// first arrived — before anybody had decided whether it should be public. With
|
||||||
|
// an allowlist the new kind is invisible until somebody adds it here, which is
|
||||||
|
// the same moment they think about it.
|
||||||
|
//
|
||||||
|
// The test holds this list against `docs/rust-link/PROTOCOL.md` §8.4's table, so
|
||||||
|
// adding a kind to the spec without classifying it fails a build rather than
|
||||||
|
// shipping an address to a public page.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kinds a public, signed-out visitor may see.
|
||||||
|
*
|
||||||
|
* Each entry is a decision. `player.chat` is here because a shard's chat is
|
||||||
|
* public by the same logic that makes a killfeed public — it happened in front
|
||||||
|
* of everyone who was on the server — and an operator who disagrees turns the
|
||||||
|
* feature off rather than relying on this list being wrong.
|
||||||
|
*/
|
||||||
|
const PUBLIC_KINDS = Object.freeze([
|
||||||
|
'player.connected',
|
||||||
|
'player.disconnected',
|
||||||
|
'player.respawned',
|
||||||
|
'player.death',
|
||||||
|
'player.chat',
|
||||||
|
'player.tally',
|
||||||
|
'server.wipe',
|
||||||
|
'server.initialized',
|
||||||
|
'server.shutdown',
|
||||||
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kinds an admin may see and nobody else.
|
||||||
|
*
|
||||||
|
* Listed rather than implied by absence, so that "we know about this kind and it
|
||||||
|
* is restricted" is distinguishable from "nobody has classified this kind" — the
|
||||||
|
* second is a finding, and a bare allowlist cannot tell you which you are
|
||||||
|
* looking at.
|
||||||
|
*/
|
||||||
|
const STAFF_KINDS = Object.freeze([
|
||||||
|
'entity.destroyed',
|
||||||
|
'player.reported',
|
||||||
|
'player.banned',
|
||||||
|
'player.unbanned',
|
||||||
|
'player.login.attempt',
|
||||||
|
'player.approved',
|
||||||
|
// Protocol 3's two account frames. Neither carries a code — the code travels
|
||||||
|
// through the player, which is what makes typing it proof — but both name a
|
||||||
|
// Steam id ALONGSIDE a website account's activity, which is exactly the join a
|
||||||
|
// public page must not be able to make: "this player is that person" is a fact
|
||||||
|
// about somebody's identity, not about what happened on the server.
|
||||||
|
'account.link.requested',
|
||||||
|
'account.unlinked',
|
||||||
|
// Protocol 4. Who holds which privilege in game, and the fact that somebody
|
||||||
|
// changed it by hand — a question about a person's standing and about an
|
||||||
|
// operator's own console, neither of which is a public page's business.
|
||||||
|
'perm.drift',
|
||||||
|
])
|
||||||
|
|
||||||
|
/** Every kind protocol 3 defines. */
|
||||||
|
const ALL_KINDS = Object.freeze([...PUBLIC_KINDS, ...STAFF_KINDS])
|
||||||
|
|
||||||
|
const PUBLIC = new Set(PUBLIC_KINDS)
|
||||||
|
const STAFF = new Set(STAFF_KINDS)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* May a signed-out visitor see this kind?
|
||||||
|
*
|
||||||
|
* Default deny: an unknown kind is not public. Callers pass whatever arrived on
|
||||||
|
* the wire, including a kind from a newer protocol this build has never seen.
|
||||||
|
*/
|
||||||
|
function isPublic(kind) {
|
||||||
|
return PUBLIC.has(kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Is this a kind this build knows about at all? */
|
||||||
|
function isKnown(kind) {
|
||||||
|
return PUBLIC.has(kind) || STAFF.has(kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Narrows a list of requested kinds to the ones a viewer may have.
|
||||||
|
*
|
||||||
|
* Returning the allowlist itself when nothing was requested is what makes the
|
||||||
|
* public route safe by construction rather than by remembering to filter: there
|
||||||
|
* is no code path where "no filter" means "everything".
|
||||||
|
*/
|
||||||
|
function kindsFor({ admin = false, requested = null } = {}) {
|
||||||
|
const permitted = admin ? ALL_KINDS : PUBLIC_KINDS
|
||||||
|
|
||||||
|
if (!requested || requested.length === 0) return [...permitted]
|
||||||
|
|
||||||
|
const allowed = new Set(permitted)
|
||||||
|
return requested.filter((k) => allowed.has(k))
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
PUBLIC_KINDS,
|
||||||
|
STAFF_KINDS,
|
||||||
|
ALL_KINDS,
|
||||||
|
isPublic,
|
||||||
|
isKnown,
|
||||||
|
kindsFor,
|
||||||
|
}
|
||||||
149
server/core.js
Normal file
149
server/core.js
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
// ── 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) },
|
||||||
|
|
||||||
|
// 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(),
|
||||||
|
|
||||||
|
// 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 },
|
||||||
|
}
|
||||||
42
server/db/purge.sql
Normal file
42
server/db/purge.sql
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
-- ── The teardown ──────────────────────────────────────────────────────────
|
||||||
|
--
|
||||||
|
-- Destructive, and run ONLY by an explicit admin purge (MODULE_API.md §2.6).
|
||||||
|
-- Nothing on the boot path executes this file, and uninstalling the module does
|
||||||
|
-- not either: removing an operator's data is a second decision they make on
|
||||||
|
-- purpose, offered inside the uninstall flow and confirmed separately.
|
||||||
|
--
|
||||||
|
-- It exists because `schema.sql` does. A module that can create tables and
|
||||||
|
-- cannot drop them leaves an operator with orphaned data and no supported way to
|
||||||
|
-- remove it, so core refuses to load a module that declares one without the
|
||||||
|
-- other.
|
||||||
|
--
|
||||||
|
-- **Drop in the reverse of creation order**, which this file depends on:
|
||||||
|
-- `rust_server_state` carries a foreign key into `rust_servers`, so dropping the
|
||||||
|
-- parent first fails on the constraint — and a purge that fails halfway leaves
|
||||||
|
-- exactly the orphaned data it exists to remove.
|
||||||
|
--
|
||||||
|
-- What does NOT belong here: rows written into core's tables. Core prunes what
|
||||||
|
-- it knows this module registered, because it is the side that knows which
|
||||||
|
-- registrant owned what.
|
||||||
|
|
||||||
|
-- Phase 7. Children before parents: every one of these carries a foreign key
|
||||||
|
-- into `rust_servers`, `users` or `rust_perm_groups`.
|
||||||
|
DROP TABLE IF EXISTS rust_perm_catalogue;
|
||||||
|
DROP TABLE IF EXISTS rust_perm_sync;
|
||||||
|
DROP TABLE IF EXISTS rust_perm_revocations;
|
||||||
|
DROP TABLE IF EXISTS rust_perm_drift;
|
||||||
|
DROP TABLE IF EXISTS rust_perm_pushed;
|
||||||
|
DROP TABLE IF EXISTS rust_perm_grants;
|
||||||
|
DROP TABLE IF EXISTS rust_perm_group_members;
|
||||||
|
DROP TABLE IF EXISTS rust_perm_group_permissions;
|
||||||
|
DROP TABLE IF EXISTS rust_perm_groups;
|
||||||
|
DROP TABLE IF EXISTS rust_account_links;
|
||||||
|
DROP TABLE IF EXISTS rust_ingest_cursor;
|
||||||
|
DROP TABLE IF EXISTS rust_presence;
|
||||||
|
DROP TABLE IF EXISTS rust_events;
|
||||||
|
DROP TABLE IF EXISTS rust_gather_totals;
|
||||||
|
DROP TABLE IF EXISTS rust_player_wipe_stats;
|
||||||
|
DROP TABLE IF EXISTS rust_players;
|
||||||
|
DROP TABLE IF EXISTS rust_wipes;
|
||||||
|
DROP TABLE IF EXISTS rust_server_state;
|
||||||
|
DROP TABLE IF EXISTS rust_servers;
|
||||||
620
server/db/schema.sql
Normal file
620
server/db/schema.sql
Normal file
@@ -0,0 +1,620 @@
|
|||||||
|
-- ── The schema fragment ───────────────────────────────────────────────────
|
||||||
|
--
|
||||||
|
-- Core replays this file on EVERY boot, statement by statement, immediately
|
||||||
|
-- after its own schema.sql and before it seeds defaults (MODULE_API.md §2.6).
|
||||||
|
--
|
||||||
|
-- There is no migration runner anywhere in this project. A module's schema is
|
||||||
|
-- not a sequence of changes to apply once — it is a statement of what the tables
|
||||||
|
-- should look like, written so that running it against a database that already
|
||||||
|
-- matches does nothing. Every CREATE carries IF NOT EXISTS; **changing a table
|
||||||
|
-- is an ALTER below the CREATE, never an edit to the CREATE**, because
|
||||||
|
-- `CREATE TABLE IF NOT EXISTS` does nothing at all when the table is already
|
||||||
|
-- there and an edited column would reach fresh installs only.
|
||||||
|
--
|
||||||
|
-- Every table here is prefixed `rust_`, which is this module's id and the only
|
||||||
|
-- prefix it may create under.
|
||||||
|
--
|
||||||
|
-- ── Four kinds of table, and the split between them is the whole design ───
|
||||||
|
--
|
||||||
|
-- CONFIGURATION `rust_servers` — rows an operator writes, from Admin → Rust.
|
||||||
|
-- OBSERVED STATE `rust_server_state`, `rust_presence` — what a sidecar last
|
||||||
|
-- reported, replaced rather than appended.
|
||||||
|
-- THE RECORD `rust_wipes`, `rust_players`, `rust_player_wipe_stats`,
|
||||||
|
-- `rust_gather_totals` — permanent, and the reason a wipe does
|
||||||
|
-- not erase a player's history.
|
||||||
|
-- THE WINDOW `rust_events` — recent detail, bounded by a sweep.
|
||||||
|
--
|
||||||
|
-- They are separate tables rather than columns on one because they have
|
||||||
|
-- different writers, different lifetimes and different audiences — and because
|
||||||
|
-- a purge of observed state while keeping the configuration is a thing an
|
||||||
|
-- operator will eventually want.
|
||||||
|
--
|
||||||
|
-- Teardown is `purge.sql`, which no boot ever runs.
|
||||||
|
|
||||||
|
|
||||||
|
-- ── The configured servers ────────────────────────────────────────────────
|
||||||
|
--
|
||||||
|
-- One row per Rust game server, and therefore one row per sidecar: the bridge is
|
||||||
|
-- one server to one sidecar, on that server's own host (R8). A community running
|
||||||
|
-- six servers has six rows here, each with its own base URL and its own token.
|
||||||
|
--
|
||||||
|
-- `id` is the operator's own slug and is what every URL under `/rust/servers/`
|
||||||
|
-- carries. It is deliberately NOT auto-increment: it appears in links people
|
||||||
|
-- share, and a row rebuilt after a mistake should be able to keep its address.
|
||||||
|
--
|
||||||
|
-- `sidecar_token_enc` holds the sidecar's shared secret **encrypted at rest**
|
||||||
|
-- through `ctx.secretBox` (MODULE_API.md §2.3), like every other secret this
|
||||||
|
-- platform stores. It is write-only in the API: the admin surface accepts a new
|
||||||
|
-- value and never returns the stored one, so a compromised admin session cannot
|
||||||
|
-- read back the credential that reaches the game host.
|
||||||
|
--
|
||||||
|
-- `protocol` records the wire version this row was configured against. It is
|
||||||
|
-- stored rather than assumed because a fleet is upgraded one host at a time, and
|
||||||
|
-- an operator needs to see WHICH server disagrees rather than that one does.
|
||||||
|
CREATE TABLE IF NOT EXISTS rust_servers (
|
||||||
|
id VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||||
|
name VARCHAR(120) NOT NULL,
|
||||||
|
sidecar_base_url VARCHAR(255) NOT NULL,
|
||||||
|
sidecar_token_enc TEXT NULL,
|
||||||
|
protocol INT UNSIGNED NOT NULL DEFAULT 1,
|
||||||
|
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
-- ── What each server last said about itself ───────────────────────────────
|
||||||
|
--
|
||||||
|
-- One row per configured server, replaced whole each time this module reads a
|
||||||
|
-- sidecar. It is the table that lets the site render while every game server is
|
||||||
|
-- off, which is the point of the sidecar holding a store at all.
|
||||||
|
--
|
||||||
|
-- `updated_at` carries no `ON UPDATE CURRENT_TIMESTAMP`, deliberately. That
|
||||||
|
-- clause fires only when an UPDATE actually CHANGES a value, so a writer sending
|
||||||
|
-- the same numbers back — which is exactly what a quiet server looks like —
|
||||||
|
-- would leave the timestamp frozen at the first write and the row would look
|
||||||
|
-- stale while nothing was wrong. The writer sets the column explicitly instead.
|
||||||
|
--
|
||||||
|
-- `boot_id` is the game process's own identity, not the sidecar's and not the
|
||||||
|
-- plugin's. It changes when the world started over and at no other time, which
|
||||||
|
-- is what makes it the thing to watch: a reconnect of either bridge component
|
||||||
|
-- loses nothing, and a game restart loses everything an event put in the world.
|
||||||
|
--
|
||||||
|
-- `raw` keeps the whole frame. This module indexes the columns it serves and
|
||||||
|
-- stores the rest verbatim, so a protocol version that adds a field needs no
|
||||||
|
-- migration here — the same dumb-forwarder property the sidecar has, one hop
|
||||||
|
-- further along.
|
||||||
|
CREATE TABLE IF NOT EXISTS rust_server_state (
|
||||||
|
server_id VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||||
|
reachable TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
online TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
players INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
max_players INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
hostname VARCHAR(191) NULL,
|
||||||
|
level VARCHAR(120) NULL,
|
||||||
|
seed BIGINT NULL,
|
||||||
|
world_size INT UNSIGNED NULL,
|
||||||
|
boot_id VARCHAR(64) NULL,
|
||||||
|
save_created_at VARCHAR(32) NULL,
|
||||||
|
protocol INT UNSIGNED NULL,
|
||||||
|
raw LONGTEXT NULL,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_rust_server_state_server
|
||||||
|
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
-- ── The read path ─────────────────────────────────────────────────────────
|
||||||
|
--
|
||||||
|
-- Protocol 2 turned the bridge from a greeting into a catalogue, and these are
|
||||||
|
-- the tables that hold it. They divide on one line, and it is the line R12 drew:
|
||||||
|
--
|
||||||
|
-- PERMANENT `rust_wipes`, `rust_players`, `rust_player_wipe_stats`,
|
||||||
|
-- `rust_gather_totals` — a player's record, kept for ever. All-time
|
||||||
|
-- is a SUM across wipes rather than a second set of counters, so
|
||||||
|
-- there is no second number that can disagree with the first.
|
||||||
|
--
|
||||||
|
-- BOUNDED `rust_events` — the recent raw window the killfeed reads, pruned
|
||||||
|
-- on a sweep. It is detail, not record: losing last month's
|
||||||
|
-- individual deaths costs a scroll-back, losing last month's
|
||||||
|
-- totals costs a player their history.
|
||||||
|
--
|
||||||
|
-- DERIVED `rust_presence` — who is on right now, replaced wholesale from
|
||||||
|
-- the `players.online` board. Never a history, never appended.
|
||||||
|
--
|
||||||
|
-- The sidecar keeps its own bounded copy of the same events (default 14 days),
|
||||||
|
-- so shortening either window loses recent detail and neither loses a total.
|
||||||
|
|
||||||
|
|
||||||
|
-- ── Wipes ─────────────────────────────────────────────────────────────────
|
||||||
|
--
|
||||||
|
-- One row per (server, wipe). The id is the plugin's, derived from the save's
|
||||||
|
-- creation time and stamped on every frame (PROTOCOL.md §8.2) — this module
|
||||||
|
-- never derives one, because two derivations of one fact eventually disagree
|
||||||
|
-- about a boundary.
|
||||||
|
--
|
||||||
|
-- Rows appear by being MENTIONED: the first frame carrying a wipe id this module
|
||||||
|
-- has not seen creates it. There is no "start a wipe" call and there must not be
|
||||||
|
-- one, because the website is not present when a wipe happens — a wipe is a fact
|
||||||
|
-- about a world that was restarted while nobody was watching.
|
||||||
|
CREATE TABLE IF NOT EXISTS rust_wipes (
|
||||||
|
server_id VARCHAR(64) NOT NULL,
|
||||||
|
wipe_id VARCHAR(48) NOT NULL,
|
||||||
|
save_created_at VARCHAR(32) NULL,
|
||||||
|
first_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (server_id, wipe_id),
|
||||||
|
CONSTRAINT fk_rust_wipes_server
|
||||||
|
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
-- ── Players ───────────────────────────────────────────────────────────────
|
||||||
|
--
|
||||||
|
-- Identity, and deliberately nothing else. It is keyed on the Steam id alone
|
||||||
|
-- and carries no server: a player is the same person on all six of a community's
|
||||||
|
-- servers, and everything that is per-server lives in the stats table.
|
||||||
|
--
|
||||||
|
-- `user_id` is NOT here. Linking a Steam id to a website account is phase 6's
|
||||||
|
-- work (R1), and a column waiting for it would be a column every read has to
|
||||||
|
-- remember is always null.
|
||||||
|
CREATE TABLE IF NOT EXISTS rust_players (
|
||||||
|
steam_id VARCHAR(32) NOT NULL PRIMARY KEY,
|
||||||
|
name VARCHAR(191) NULL,
|
||||||
|
first_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
-- ── The permanent record ──────────────────────────────────────────────────
|
||||||
|
--
|
||||||
|
-- One row per player per wipe per server, and the only counters this module
|
||||||
|
-- keeps. R12's "per-wipe detail plus all-time rollups" is satisfied by SUMming
|
||||||
|
-- this rather than by maintaining a second all-time row, because two counters
|
||||||
|
-- for one fact drift the first time an ingest is replayed.
|
||||||
|
--
|
||||||
|
-- Every column is a COUNT that only ever goes up within a wipe, which is what
|
||||||
|
-- makes ingest idempotent-ish in the only way that matters: the cursor advances
|
||||||
|
-- only after the batch commits, so a crash re-reads a batch it has not counted.
|
||||||
|
--
|
||||||
|
-- `playtime_sec` comes from `sessionSec` on a disconnect, and a session whose
|
||||||
|
-- start this module never saw contributes NOTHING rather than zero — the plugin
|
||||||
|
-- omits the field, the ingest skips it, and the number stays honestly short
|
||||||
|
-- instead of quietly wrong.
|
||||||
|
CREATE TABLE IF NOT EXISTS rust_player_wipe_stats (
|
||||||
|
server_id VARCHAR(64) NOT NULL,
|
||||||
|
wipe_id VARCHAR(48) NOT NULL,
|
||||||
|
steam_id VARCHAR(32) NOT NULL,
|
||||||
|
kills INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
deaths INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
suicides INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
npc_kills INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
structures INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
sessions INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
playtime_sec BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
last_seen DATETIME NULL,
|
||||||
|
PRIMARY KEY (server_id, wipe_id, steam_id),
|
||||||
|
KEY idx_rust_stats_kills (server_id, wipe_id, kills DESC),
|
||||||
|
KEY idx_rust_stats_player (steam_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
-- ── What they gathered ────────────────────────────────────────────────────
|
||||||
|
--
|
||||||
|
-- A row per resource rather than a JSON blob on the stats row, for one reason:
|
||||||
|
-- the leaderboard question is "who gathered the most sulfur this wipe", and that
|
||||||
|
-- is an ORDER BY over a column in every SQL engine and a JSON function call in
|
||||||
|
-- exactly one. The resource name is the game's own shortname, unknown in advance
|
||||||
|
-- and not worth a lookup table.
|
||||||
|
CREATE TABLE IF NOT EXISTS rust_gather_totals (
|
||||||
|
server_id VARCHAR(64) NOT NULL,
|
||||||
|
wipe_id VARCHAR(48) NOT NULL,
|
||||||
|
steam_id VARCHAR(32) NOT NULL,
|
||||||
|
resource VARCHAR(64) NOT NULL,
|
||||||
|
amount BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (server_id, wipe_id, steam_id, resource),
|
||||||
|
KEY idx_rust_gather_top (server_id, wipe_id, resource, amount DESC)
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
-- ── The recent raw window ─────────────────────────────────────────────────
|
||||||
|
--
|
||||||
|
-- Every ingested event, whole, for as long as the retention sweep keeps it. The
|
||||||
|
-- killfeed reads this; so does an admin looking at what happened.
|
||||||
|
--
|
||||||
|
-- `raw` holds the entire frame and the columns beside it are only what a query
|
||||||
|
-- needs to reach — the same rule the sidecar's own store follows, one hop along:
|
||||||
|
-- a protocol version that adds a field needs no migration here.
|
||||||
|
--
|
||||||
|
-- **`kind` is a security boundary, not a label.** Some kinds carry IP addresses
|
||||||
|
-- and player reports (PROTOCOL.md §8.4), and what makes them safe is that the
|
||||||
|
-- public read is filtered by an allowlist this module holds, default-deny. The
|
||||||
|
-- rows are stored either way, because an operator chasing ban evasion needs them.
|
||||||
|
CREATE TABLE IF NOT EXISTS rust_events (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
server_id VARCHAR(64) NOT NULL,
|
||||||
|
wipe_id VARCHAR(48) NULL,
|
||||||
|
kind VARCHAR(64) NOT NULL,
|
||||||
|
t BIGINT NOT NULL,
|
||||||
|
steam_id VARCHAR(32) NULL,
|
||||||
|
raw LONGTEXT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
KEY idx_rust_events_server (server_id, id DESC),
|
||||||
|
KEY idx_rust_events_kind (server_id, kind, id DESC),
|
||||||
|
KEY idx_rust_events_wipe (server_id, wipe_id, id DESC),
|
||||||
|
KEY idx_rust_events_created (created_at)
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
-- ── Who is on right now ───────────────────────────────────────────────────
|
||||||
|
--
|
||||||
|
-- Replaced wholesale every time the `players.online` board arrives, which is on
|
||||||
|
-- every bridge connect and every 60 seconds. It is a BOARD, and the reason it is
|
||||||
|
-- its own table rather than rows in `rust_events` is that a board answers "now"
|
||||||
|
-- and an event answers "then"; storing a board as history is the mistake the
|
||||||
|
-- wire's `type` field exists to prevent, and it would be a shame to make it here
|
||||||
|
-- after the sidecar went to the trouble of not making it there.
|
||||||
|
CREATE TABLE IF NOT EXISTS rust_presence (
|
||||||
|
server_id VARCHAR(64) NOT NULL,
|
||||||
|
steam_id VARCHAR(32) NOT NULL,
|
||||||
|
name VARCHAR(191) NULL,
|
||||||
|
sleeping TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
connected_at DATETIME NULL,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (server_id, steam_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
-- ── The ingest cursor ─────────────────────────────────────────────────────
|
||||||
|
--
|
||||||
|
-- Where this module has read up to in each sidecar's feed. One row per server.
|
||||||
|
--
|
||||||
|
-- It is persisted rather than held in memory because the alternative is a module
|
||||||
|
-- that re-reads everything on every boot or nothing at all, and both are wrong in
|
||||||
|
-- a way that only shows up in production. The cursor advances **after** the batch
|
||||||
|
-- is written, never before: a crash mid-batch re-reads rows it has not counted,
|
||||||
|
-- which is the safe direction to be wrong in.
|
||||||
|
--
|
||||||
|
-- A NEW server starts at the sidecar's current end rather than at zero (see
|
||||||
|
-- `GET /feed` with no `since`). A module installed today against a sidecar that
|
||||||
|
-- has been running a month wants what happens next — replaying a fortnight of
|
||||||
|
-- deaths into stats whose wipes it never saw is not a catch-up, it is a
|
||||||
|
-- fabrication of history it was not present for.
|
||||||
|
CREATE TABLE IF NOT EXISTS rust_ingest_cursor (
|
||||||
|
server_id VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||||
|
last_event_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
events_seen BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_rust_cursor_server
|
||||||
|
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
-- ── Who owns which Steam account ──────────────────────────────────────────
|
||||||
|
--
|
||||||
|
-- R1's identity link, and the reason it is a table rather than a column on
|
||||||
|
-- `rust_players`: a link is a fact about a WEBSITE USER that happens to be keyed
|
||||||
|
-- by a Steam id, and it outlives every row this module writes about play. A
|
||||||
|
-- column here would be null for the overwhelming majority of players and would
|
||||||
|
-- be deleted by any sweep that pruned inactive ones.
|
||||||
|
--
|
||||||
|
-- **Keyed on `steam_id` alone, fleet-wide.** `rust_players` already made that
|
||||||
|
-- call in protocol 2 and it is the truth of the thing: a Steam account is one
|
||||||
|
-- person across every server an operator runs, where stats are per server and
|
||||||
|
-- per wipe. Linking on one server links for the fleet, because there is nothing
|
||||||
|
-- else it could honestly mean.
|
||||||
|
--
|
||||||
|
-- **One Steam id, at most one user** — that is what the primary key buys, and it
|
||||||
|
-- is load-bearing rather than tidy. Phase 7 makes the site the author of who may
|
||||||
|
-- do what in game and phase 13 makes it the thing that hands out loot; both are
|
||||||
|
-- grants against a Steam id, and both assume the question "whose is this?" has
|
||||||
|
-- exactly one answer.
|
||||||
|
--
|
||||||
|
-- The reverse is deliberately NOT constrained: one website user may hold several
|
||||||
|
-- Steam accounts. People have a second account, or a family shares a site login,
|
||||||
|
-- and refusing that would be inventing a rule the game does not have.
|
||||||
|
--
|
||||||
|
-- `ON DELETE CASCADE` from `users`: a deleted account's links go with it. The
|
||||||
|
-- alternative is a row naming a user id that resolves to nobody, which every
|
||||||
|
-- read would then have to defend against.
|
||||||
|
CREATE TABLE IF NOT EXISTS rust_account_links (
|
||||||
|
steam_id VARCHAR(32) NOT NULL PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
-- What the player was called in game when they linked. A display name, kept
|
||||||
|
-- so an operator reading the admin panel sees a person rather than a number;
|
||||||
|
-- never used to identify anybody, because a Rust name changes on a whim.
|
||||||
|
name VARCHAR(191) NULL,
|
||||||
|
-- Which server minted the code. Not part of the identity — the link is
|
||||||
|
-- fleet-wide — but an operator asking "where did this come from" has no other
|
||||||
|
-- way to find out, and a support conversation starts there.
|
||||||
|
server_id VARCHAR(64) NULL,
|
||||||
|
linked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_rust_links_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
KEY idx_rust_links_user (user_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
|
||||||
|
-- ── Site-owned permissions (phase 7, R2) ──────────────────────────────────
|
||||||
|
--
|
||||||
|
-- The website is the author of record for who may do what in game, and the
|
||||||
|
-- framework's own permission store is an ENFORCEMENT CACHE. That is one
|
||||||
|
-- sentence with three consequences, and the tables below are shaped by them:
|
||||||
|
--
|
||||||
|
-- • Every third-party plugin honours a site grant with no adapter, because
|
||||||
|
-- they all already call `UserHasPermission`. Nothing here is read by the
|
||||||
|
-- game directly; it is pushed into the store the game already consults.
|
||||||
|
-- • A wipe stops being a data-loss event. The game forgets and the site does
|
||||||
|
-- not, so the next sync puts it all back.
|
||||||
|
-- • A hand edit is REPORTED, never silently overwritten (D31). Which means
|
||||||
|
-- the site has to be able to tell a grant it made from one somebody typed
|
||||||
|
-- at a console — and that is a fact only the site can hold, because the
|
||||||
|
-- store records who granted a permission nowhere.
|
||||||
|
--
|
||||||
|
-- ── A grant is against a WEBSITE USER (D28) ───────────────────────────────
|
||||||
|
--
|
||||||
|
-- Not against a Steam id, though a Steam id is what reaches the game. The site
|
||||||
|
-- authors privilege for a PERSON: phase 13's earned entitlements follow whoever
|
||||||
|
-- earned them, and an account unlinked from a person takes their privileges
|
||||||
|
-- with it. The Steam ids are resolved from `rust_account_links` at push time,
|
||||||
|
-- so a player who links a second account gets what they hold on both — which is
|
||||||
|
-- the honest reading of "this person may do this".
|
||||||
|
--
|
||||||
|
-- A user with no linked account is authored against perfectly well and simply
|
||||||
|
-- reaches nobody until they link. That is visible on the admin screen rather
|
||||||
|
-- than silent, because a grant that reaches nothing looks identical to a grant
|
||||||
|
-- that worked from every other angle.
|
||||||
|
--
|
||||||
|
-- ── Scope (D29) ───────────────────────────────────────────────────────────
|
||||||
|
--
|
||||||
|
-- Every authored row carries one: a server id, or `*` for the whole fleet. The
|
||||||
|
-- game stores permissions per server (each has its own store), an operator
|
||||||
|
-- running a modded server and a vanilla one will not want one set on both, and
|
||||||
|
-- a single-server community never has to think about it.
|
||||||
|
|
||||||
|
|
||||||
|
-- ── Groups ────────────────────────────────────────────────────────────────
|
||||||
|
--
|
||||||
|
-- Mirrored into the game as REAL groups (D30) rather than flattened into
|
||||||
|
-- per-player grants. Third-party plugins read group membership, BetterChat's
|
||||||
|
-- group API (R15, phase 17) has something to hang on, and an operator reading
|
||||||
|
-- `oxide.show groups` sees what the website shows.
|
||||||
|
--
|
||||||
|
-- The cost of that fidelity is written down in PLAN.md §12.2 rule 4 and does
|
||||||
|
-- not go away: **a player the store has never seen cannot be put in a group**,
|
||||||
|
-- while a direct grant to the same id works immediately. The sync reports those
|
||||||
|
-- members as pending and the membership lands on their first connection.
|
||||||
|
--
|
||||||
|
-- The name is the primary key, fleet-wide, even though the row carries a scope:
|
||||||
|
-- one `vip` on the site is one `vip` in the game, pushed to the servers its
|
||||||
|
-- scope names. Two groups of the same name with different scopes would be two
|
||||||
|
-- definitions of one name in every store that received both.
|
||||||
|
CREATE TABLE IF NOT EXISTS rust_perm_groups (
|
||||||
|
name VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||||
|
title VARCHAR(120) NOT NULL DEFAULT '',
|
||||||
|
rank INT NOT NULL DEFAULT 0,
|
||||||
|
scope VARCHAR(64) NOT NULL DEFAULT '*',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
-- What each group carries. A row per permission rather than a list on the group
|
||||||
|
-- for the ordinary reason: "which groups grant kits.vip" is the question an
|
||||||
|
-- operator asks when they are about to remove a plugin, and that is a WHERE
|
||||||
|
-- clause here and a scan of every row in the other shape.
|
||||||
|
CREATE TABLE IF NOT EXISTS rust_perm_group_permissions (
|
||||||
|
group_name VARCHAR(64) NOT NULL,
|
||||||
|
permission VARCHAR(128) NOT NULL,
|
||||||
|
PRIMARY KEY (group_name, permission),
|
||||||
|
CONSTRAINT fk_rust_perm_group_permissions_group
|
||||||
|
FOREIGN KEY (group_name) REFERENCES rust_perm_groups (name) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
-- Who is in each group — by website user, like every other authored row.
|
||||||
|
--
|
||||||
|
-- `added_by` is an admin's user id and deliberately carries NO foreign key: a
|
||||||
|
-- staff member's account being deleted must not delete the record of what they
|
||||||
|
-- did, and `ON DELETE SET NULL` would quietly rewrite history to "nobody".
|
||||||
|
-- The activity log is the audit trail; this column is a convenience beside it.
|
||||||
|
CREATE TABLE IF NOT EXISTS rust_perm_group_members (
|
||||||
|
group_name VARCHAR(64) NOT NULL,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
added_by INT NULL,
|
||||||
|
added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (group_name, user_id),
|
||||||
|
KEY idx_rust_perm_members_user (user_id),
|
||||||
|
CONSTRAINT fk_rust_perm_members_group
|
||||||
|
FOREIGN KEY (group_name) REFERENCES rust_perm_groups (name) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_rust_perm_members_user
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
-- ── Direct grants ─────────────────────────────────────────────────────────
|
||||||
|
--
|
||||||
|
-- A permission held by one person, without a group. It is not a lesser version
|
||||||
|
-- of membership: it is the shape that reaches a player who has never connected
|
||||||
|
-- to that server, which is exactly what an entitlement earned on the website at
|
||||||
|
-- three in the morning has to do (R16).
|
||||||
|
--
|
||||||
|
-- `source` is why this table does not need changing in phase 13. Every later
|
||||||
|
-- author — an event action granting the right to redeem a kit, a lease handing
|
||||||
|
-- out a weekend group — writes a row here with its own source rather than a
|
||||||
|
-- store of its own, so there is one answer to "why does this player have this"
|
||||||
|
-- and one place the push reads.
|
||||||
|
CREATE TABLE IF NOT EXISTS rust_perm_grants (
|
||||||
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
permission VARCHAR(128) NOT NULL,
|
||||||
|
scope VARCHAR(64) NOT NULL DEFAULT '*',
|
||||||
|
source VARCHAR(32) NOT NULL DEFAULT 'admin',
|
||||||
|
note VARCHAR(255) NULL,
|
||||||
|
granted_by INT NULL,
|
||||||
|
granted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uq_rust_perm_grant (user_id, permission, scope),
|
||||||
|
KEY idx_rust_perm_grant_user (user_id),
|
||||||
|
CONSTRAINT fk_rust_perm_grants_user
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
-- ── What this site has actually put in each game ──────────────────────────
|
||||||
|
--
|
||||||
|
-- The site's memory of its own authorship, one row per thing it has confirmed
|
||||||
|
-- into one server's store. It is the table that makes D31 possible at all.
|
||||||
|
--
|
||||||
|
-- Three sets, and every interesting question is the difference between two of
|
||||||
|
-- them:
|
||||||
|
--
|
||||||
|
-- desired − pushed what to apply
|
||||||
|
-- pushed − desired what to RETIRE, because the site put it there and has
|
||||||
|
-- since withdrawn it
|
||||||
|
-- present − desired drift: somebody else put it there
|
||||||
|
--
|
||||||
|
-- Without the middle row a withdrawn grant is indistinguishable from a hand
|
||||||
|
-- edit, and those two have opposite correct answers. Inferring it from absence
|
||||||
|
-- is the mistake this table exists to prevent.
|
||||||
|
--
|
||||||
|
-- It is keyed by Steam id rather than by user, because it records what is in the
|
||||||
|
-- GAME, and the game has never heard of a website account. Unlinking an account
|
||||||
|
-- therefore leaves its row here until the next sync retires it — which is the
|
||||||
|
-- correct behaviour and would be impossible to express keyed the other way.
|
||||||
|
CREATE TABLE IF NOT EXISTS rust_perm_pushed (
|
||||||
|
server_id VARCHAR(64) NOT NULL,
|
||||||
|
-- `grant` | `member` | `group-permission` | `group`
|
||||||
|
kind VARCHAR(24) NOT NULL,
|
||||||
|
-- a Steam id, or a group name
|
||||||
|
subject VARCHAR(64) NOT NULL,
|
||||||
|
-- a permission, a group name, or '' for the existence of a group
|
||||||
|
object VARCHAR(128) NOT NULL,
|
||||||
|
pushed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (server_id, kind, subject, object),
|
||||||
|
CONSTRAINT fk_rust_perm_pushed_server
|
||||||
|
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
-- ── Drift ─────────────────────────────────────────────────────────────────
|
||||||
|
--
|
||||||
|
-- What a sync found in a server's store that the site did not author, within
|
||||||
|
-- the namespace the site claims. Rows appear and disappear with the report:
|
||||||
|
-- this is the CURRENT difference, not a history of differences, and a hand edit
|
||||||
|
-- that somebody has since removed should stop being on the screen.
|
||||||
|
--
|
||||||
|
-- Nothing here is ever removed from the game by the sync itself. An operator
|
||||||
|
-- typing `oxide.grant` during an incident is drift, not an error, and the two
|
||||||
|
-- answers offered to them — adopt it, or revoke it — are both a person's
|
||||||
|
-- decision.
|
||||||
|
CREATE TABLE IF NOT EXISTS rust_perm_drift (
|
||||||
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
server_id VARCHAR(64) NOT NULL,
|
||||||
|
kind VARCHAR(24) NOT NULL,
|
||||||
|
subject VARCHAR(64) NOT NULL,
|
||||||
|
object VARCHAR(128) NOT NULL,
|
||||||
|
first_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uq_rust_perm_drift (server_id, kind, subject, object),
|
||||||
|
CONSTRAINT fk_rust_perm_drift_server
|
||||||
|
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
-- ── Removing something the site never put there ───────────────────────────
|
||||||
|
--
|
||||||
|
-- Revoking a drift row cannot go through `rust_perm_pushed`, because the whole
|
||||||
|
-- point of a drift row is that it was never pushed. It cannot go through the
|
||||||
|
-- authored tables either: a foreign grant often names a Steam id that belongs
|
||||||
|
-- to no website account at all, and there is no user to author it against.
|
||||||
|
--
|
||||||
|
-- So a revoke is its own instruction with its own lifetime: queued by a person,
|
||||||
|
-- carried in the next sync's retire list, and deleted once a report says the
|
||||||
|
-- game no longer has it. A server that is offline keeps the instruction until
|
||||||
|
-- it comes back, which is the behaviour an operator expects from a website that
|
||||||
|
-- claims to be the author of record.
|
||||||
|
CREATE TABLE IF NOT EXISTS rust_perm_revocations (
|
||||||
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
server_id VARCHAR(64) NOT NULL,
|
||||||
|
kind VARCHAR(24) NOT NULL,
|
||||||
|
subject VARCHAR(64) NOT NULL,
|
||||||
|
object VARCHAR(128) NOT NULL,
|
||||||
|
requested_by INT NULL,
|
||||||
|
requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uq_rust_perm_revocation (server_id, kind, subject, object),
|
||||||
|
CONSTRAINT fk_rust_perm_revocations_server
|
||||||
|
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
-- ── The state of the mirror, per server ───────────────────────────────────
|
||||||
|
--
|
||||||
|
-- One row per configured server: whether its store currently matches what the
|
||||||
|
-- site authors, when that was last true, and what the last report said.
|
||||||
|
--
|
||||||
|
-- `dirty` is how everything that should provoke a sync says so without knowing
|
||||||
|
-- anything about syncing: an admin writing a grant, a drift hook firing in the
|
||||||
|
-- game, a server reporting a new boot id or a new wipe. The loop owns WHEN, and
|
||||||
|
-- every other part of the module owns WHETHER.
|
||||||
|
--
|
||||||
|
-- `desired_hash` and `synced_hash` are the cheap half of that question. A loop
|
||||||
|
-- that pushed the whole set every tick would work and would also write to six
|
||||||
|
-- game servers every thirty seconds for ever; comparing a hash costs one query
|
||||||
|
-- and skips the round trip when nothing has changed. The periodic audit below
|
||||||
|
-- is what keeps that from being a way to never notice drift.
|
||||||
|
CREATE TABLE IF NOT EXISTS rust_perm_sync (
|
||||||
|
server_id VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||||
|
-- `pending` | `ok` | `failed`
|
||||||
|
state VARCHAR(24) NOT NULL DEFAULT 'pending',
|
||||||
|
dirty TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
desired_hash VARCHAR(64) NULL,
|
||||||
|
synced_hash VARCHAR(64) NULL,
|
||||||
|
boot_id VARCHAR(64) NULL,
|
||||||
|
wipe_id VARCHAR(48) NULL,
|
||||||
|
last_attempt_at DATETIME NULL,
|
||||||
|
last_ok_at DATETIME NULL,
|
||||||
|
report LONGTEXT NULL,
|
||||||
|
error VARCHAR(191) NULL,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_rust_perm_sync_server
|
||||||
|
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
-- ── What each server's plugins have registered ────────────────────────────
|
||||||
|
--
|
||||||
|
-- The option source the authoring form offers (D33), cached from the live read
|
||||||
|
-- so that opening the form is not six round trips to six game hosts.
|
||||||
|
--
|
||||||
|
-- It is a cache of a fact that changes when an operator loads a plugin, and it
|
||||||
|
-- is refreshed on every sync — which is also why a name that has stopped being
|
||||||
|
-- registered disappears from the form rather than lingering as a choice that
|
||||||
|
-- silently does nothing.
|
||||||
|
CREATE TABLE IF NOT EXISTS rust_perm_catalogue (
|
||||||
|
server_id VARCHAR(64) NOT NULL,
|
||||||
|
permission VARCHAR(128) NOT NULL,
|
||||||
|
seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (server_id, permission),
|
||||||
|
CONSTRAINT fk_rust_perm_catalogue_server
|
||||||
|
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
-- ── Changes to tables that already shipped ────────────────────────────────
|
||||||
|
--
|
||||||
|
-- An ALTER below the CREATE, never an edit to it: `CREATE TABLE IF NOT EXISTS`
|
||||||
|
-- does nothing against a database that already has the table, so an edited column
|
||||||
|
-- would reach fresh installs only — which is the worst possible distribution for
|
||||||
|
-- a schema change, because it works everywhere it is tested.
|
||||||
|
ALTER TABLE rust_server_state ADD COLUMN IF NOT EXISTS wipe_id VARCHAR(48) NULL;
|
||||||
|
|
||||||
|
-- Phase 4. `updated_at` is when THIS module last wrote the row, which is not the
|
||||||
|
-- same fact as when the server last said something — and the pages were reading
|
||||||
|
-- the first as if it were the second, so a server that had been down for three
|
||||||
|
-- days rendered "last reported just now" on every failed poll.
|
||||||
|
--
|
||||||
|
-- They are genuinely two facts and both are wanted: `updated_at` decides whether
|
||||||
|
-- the row is stale (a module that stopped polling must not leave a page claiming
|
||||||
|
-- a server is up), and `last_seen_at` is when a `server.hello` last arrived. Only
|
||||||
|
-- a successful refresh moves it.
|
||||||
|
ALTER TABLE rust_server_state ADD COLUMN IF NOT EXISTS last_seen_at DATETIME NULL;
|
||||||
121
server/index.js
Normal file
121
server/index.js
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
// ── 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 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)
|
||||||
|
|
||||||
|
// 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 Team provider, 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',
|
||||||
|
})
|
||||||
|
}
|
||||||
289
server/ingest.js
Normal file
289
server/ingest.js
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
// ── Reading a sidecar's feed, and turning it into a record ────────────────
|
||||||
|
//
|
||||||
|
// One job: move each server's cursor forward, and apply what it passed.
|
||||||
|
//
|
||||||
|
// ── Why a cursor and not a socket ─────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The obvious design is a WebSocket — the sidecar has one, and module-uo takes
|
||||||
|
// exactly that route for the UO bridge. This module polls a cursor instead, and
|
||||||
|
// the reason is not laziness about latency.
|
||||||
|
//
|
||||||
|
// Core runs on Node 20, where a global `WebSocket` is still behind a flag, so a
|
||||||
|
// socket means taking `ws` as a runtime dependency — and this module's release
|
||||||
|
// asserts that it has none (D5: everything it needs arrives on `ctx`, and the
|
||||||
|
// bundle ships no `node_modules`). That is a cost worth paying for latency, but
|
||||||
|
// the deciding argument is the other one: **a socket needs a cursor anyway.**
|
||||||
|
// Whatever a feed misses while a module is restarting has to be caught up from
|
||||||
|
// somewhere, and the catch-up path is the one that must be right. A socket on
|
||||||
|
// top of a cursor is two mechanisms where the second is load-bearing; a cursor
|
||||||
|
// alone is one mechanism that is exercised every few seconds rather than only
|
||||||
|
// after an outage nobody planned.
|
||||||
|
//
|
||||||
|
// What it costs is seconds of latency on a killfeed. What it buys is that the
|
||||||
|
// path which recovers from a five-hour outage is the same path that ran a moment
|
||||||
|
// ago.
|
||||||
|
//
|
||||||
|
// ── The ordering the whole thing rests on ─────────────────────────────────
|
||||||
|
//
|
||||||
|
// **The cursor advances after the batch is written, never before.** A crash
|
||||||
|
// between the two re-reads events already counted, which inflates a total; a
|
||||||
|
// crash the other way round loses them silently and for ever. Neither is good and
|
||||||
|
// they are not equally bad — one is visible and bounded, the other is invisible
|
||||||
|
// and permanent — so the code is arranged to fail in the visible direction.
|
||||||
|
|
||||||
|
const core = require('./core')
|
||||||
|
|
||||||
|
const db = require('./model/events/events.db')
|
||||||
|
const links = require('./model/links/links.model')
|
||||||
|
const permissionsDb = require('./model/permissions/permissions.db')
|
||||||
|
const sidecar = require('./sidecarClient')
|
||||||
|
|
||||||
|
const log = core.logger('ingest')
|
||||||
|
|
||||||
|
/** How many events to ask for at once. */
|
||||||
|
const BATCH = 200
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many batches one tick will drain before letting the loop breathe.
|
||||||
|
*
|
||||||
|
* A module that has been down for a day has thousands of events waiting, and
|
||||||
|
* draining them in one unbounded loop would hold the tick — and a pool
|
||||||
|
* connection — for as long as that takes. Bounded, it catches up over several
|
||||||
|
* ticks and the site stays responsive while it does.
|
||||||
|
*/
|
||||||
|
const MAX_BATCHES_PER_TICK = 10
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies one feed item.
|
||||||
|
*
|
||||||
|
* Every frame is stored raw, and only some of them move a counter. That split is
|
||||||
|
* deliberate: the raw row is what an admin reads and what a later phase can
|
||||||
|
* re-derive from, and the counters are what a leaderboard sums. A kind this
|
||||||
|
* build has never heard of still lands in `rust_events` — it costs nothing and
|
||||||
|
* the alternative is losing the one copy of an event the next version will know
|
||||||
|
* how to read.
|
||||||
|
*/
|
||||||
|
async function apply(serverId, item) {
|
||||||
|
const frame = (item && item.frame) || {}
|
||||||
|
const kind = item.kind || frame.kind
|
||||||
|
const wipeId = frame.wipeId || null
|
||||||
|
|
||||||
|
// A wipe exists because something mentioned it. There is no "a wipe started"
|
||||||
|
// call and there must not be one: the website is not there when a wipe happens.
|
||||||
|
await db.touchWipe(serverId, wipeId, frame.saveCreatedAt || null)
|
||||||
|
|
||||||
|
await db.insertEvent({
|
||||||
|
serverId,
|
||||||
|
wipeId,
|
||||||
|
kind,
|
||||||
|
t: Number(frame.t) || item.t || Date.now(),
|
||||||
|
steamId: frame.steamId || null,
|
||||||
|
raw: frame,
|
||||||
|
})
|
||||||
|
|
||||||
|
const at = { serverId, wipeId, steamId: frame.steamId }
|
||||||
|
|
||||||
|
switch (kind) {
|
||||||
|
case 'player.connected':
|
||||||
|
await db.touchPlayer(frame.steamId, frame.name || null)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'player.disconnected': {
|
||||||
|
await db.touchPlayer(frame.steamId, frame.name || null)
|
||||||
|
|
||||||
|
// `sessionSec` is ABSENT when the plugin never saw the connect — a player
|
||||||
|
// already on the server when it loaded. Absent is not zero: adding a zero
|
||||||
|
// would be recording a session of no length, which is a different claim
|
||||||
|
// from recording no session, and it is the one that quietly under-reports
|
||||||
|
// playtime for ever.
|
||||||
|
const seconds = Number(frame.sessionSec)
|
||||||
|
await db.addStats(at, {
|
||||||
|
sessions: Number.isFinite(seconds) ? 1 : 0,
|
||||||
|
playtimeSec: Number.isFinite(seconds) && seconds > 0 ? seconds : 0,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'player.death': {
|
||||||
|
await db.touchPlayer(frame.steamId, frame.name || null)
|
||||||
|
|
||||||
|
// A suicide is a death AND a suicide, not one instead of the other: the
|
||||||
|
// deaths column is "how many times did this player die", and a leaderboard
|
||||||
|
// that silently omitted self-inflicted ones would disagree with the
|
||||||
|
// killfeed sitting next to it on the same page.
|
||||||
|
await db.addStats(at, { deaths: 1, suicides: frame.attackerType === 'self' ? 1 : 0 })
|
||||||
|
|
||||||
|
// Only a real player's kill counts. `npc` and `environment` have no
|
||||||
|
// attacker to credit, and `self` must not credit the victim with a kill —
|
||||||
|
// which is the one line here that would look right in review and produce a
|
||||||
|
// leaderboard topped by whoever died the most.
|
||||||
|
if (frame.attackerType === 'player' && frame.attackerId) {
|
||||||
|
await db.touchPlayer(frame.attackerId, frame.attackerName || null)
|
||||||
|
await db.addStats({ ...at, steamId: frame.attackerId }, { kills: 1 })
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'player.tally': {
|
||||||
|
await db.touchPlayer(frame.steamId, frame.name || null)
|
||||||
|
await db.addStats(at, {
|
||||||
|
npcKills: Number(frame.npcKills) || 0,
|
||||||
|
structures: Number(frame.structures) || 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
// A tally is a DELTA since the last flush, which is what makes adding it
|
||||||
|
// correct. If it ever becomes a running total this loop doubles every
|
||||||
|
// number in it, slowly, and looks right the whole time.
|
||||||
|
const gathered = frame.gathered || {}
|
||||||
|
for (const [resource, amount] of Object.entries(gathered)) {
|
||||||
|
await db.addGathered(at, resource, Number(amount) || 0)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'player.chat':
|
||||||
|
case 'player.respawned':
|
||||||
|
await db.touchPlayer(frame.steamId, frame.name || null)
|
||||||
|
break
|
||||||
|
|
||||||
|
// ── Protocol 3: the one frame that changes something other than a counter ──
|
||||||
|
//
|
||||||
|
// `/unlink` in game severs the site's link, and it is the only way out of a
|
||||||
|
// link on the wrong account: the site REFUSES to move a Steam id another
|
||||||
|
// website account already holds (D23), so without this a player who linked
|
||||||
|
// while signed in as the wrong account would need staff.
|
||||||
|
//
|
||||||
|
// It arrives here rather than through a route because the plugin has nothing
|
||||||
|
// to delete — the site is the author of record and the game holds no link —
|
||||||
|
// so `/unlink` is the game reporting what the player asked for, applied off
|
||||||
|
// the feed like every other frame.
|
||||||
|
//
|
||||||
|
// **The authority is the Steam account itself.** Whoever is connected to the
|
||||||
|
// game as it is who it is, which is a stronger proof of ownership than the
|
||||||
|
// site can obtain any other way, so this is not scoped by website user.
|
||||||
|
case 'account.unlinked':
|
||||||
|
await db.touchPlayer(frame.steamId, frame.name || null)
|
||||||
|
await links.unlinkFromGame(frame.steamId)
|
||||||
|
break
|
||||||
|
|
||||||
|
// Stored and counted as a sighting, nothing more. The code is deliberately
|
||||||
|
// NOT on this frame — it travels through the player — so there is nothing
|
||||||
|
// here to redeem and no pending state for the site to hold. It exists so an
|
||||||
|
// operator can see linking being used at all.
|
||||||
|
case 'account.link.requested':
|
||||||
|
await db.touchPlayer(frame.steamId, frame.name || null)
|
||||||
|
break
|
||||||
|
|
||||||
|
// ── Protocol 4: somebody changed the permission store, and it was not us ──
|
||||||
|
//
|
||||||
|
// The plugin raises this only for writes it did not make itself — its own
|
||||||
|
// sync suppresses the hooks while it applies (PROTOCOL.md §10.4). What
|
||||||
|
// arrives here is therefore a hand edit, a console command, or another
|
||||||
|
// plugin granting something.
|
||||||
|
//
|
||||||
|
// **It is a reason to reconcile, not the reconciliation.** This frame cannot
|
||||||
|
// say whether the change is foreign: only the desired set can, and that
|
||||||
|
// comparison happens in the sync. So the server is marked dirty and the next
|
||||||
|
// tick produces the authoritative answer — which means a hook that stops
|
||||||
|
// firing on a framework upgrade costs latency and nothing else. The audit
|
||||||
|
// interval finds the same drift within fifteen minutes either way.
|
||||||
|
case 'perm.drift':
|
||||||
|
await permissionsDb.markDirty(serverId)
|
||||||
|
break
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Stored, not counted. Moderation frames, the server lifecycle, and
|
||||||
|
// anything a newer protocol sends that this build does not understand.
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Brings one server's cursor up to date.
|
||||||
|
*
|
||||||
|
* Returns the number of events applied, for the log and for the tests.
|
||||||
|
*/
|
||||||
|
async function ingestServer(server) {
|
||||||
|
const cursor = await db.getCursor(server.id)
|
||||||
|
|
||||||
|
// A server this module has never ingested starts at the sidecar's CURRENT end,
|
||||||
|
// not at zero. A module installed today against a sidecar that has been running
|
||||||
|
// for a month should read what happens next — replaying a fortnight of deaths
|
||||||
|
// into stats for wipes it never saw is not a catch-up, it is inventing a
|
||||||
|
// history it was not present for. `/feed` with no `since` asks exactly that
|
||||||
|
// question, which is why the sidecar answers it that way.
|
||||||
|
if (!cursor) {
|
||||||
|
const tail = await sidecar.feedTail(server)
|
||||||
|
|
||||||
|
if (!tail.ok || !tail.data) {
|
||||||
|
// Unreachable. Write nothing: a cursor of 0 written now would replay the
|
||||||
|
// whole retained history the moment the sidecar came back.
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.setCursor(server.id, Number(tail.data.lastId) || 0, 0)
|
||||||
|
log.info('cursor started at the feed tail', { server: server.id, at: tail.data.lastId })
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
let since = Number(cursor.lastEventId) || 0
|
||||||
|
let applied = 0
|
||||||
|
|
||||||
|
for (let batch = 0; batch < MAX_BATCHES_PER_TICK; batch += 1) {
|
||||||
|
const res = await sidecar.feed(server, since, BATCH)
|
||||||
|
|
||||||
|
if (!res.ok || !res.data) return applied
|
||||||
|
|
||||||
|
const items = Array.isArray(res.data.items) ? res.data.items : []
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
try {
|
||||||
|
await apply(server.id, item)
|
||||||
|
applied += 1
|
||||||
|
} catch (err) {
|
||||||
|
// One malformed event must not wedge a server's cursor for ever. It is
|
||||||
|
// logged with its id so it can be found, and the cursor moves past it:
|
||||||
|
// the alternative is an ingest that stops at a single bad row and then
|
||||||
|
// silently stops being a feed at all.
|
||||||
|
log.warn('could not apply an event', {
|
||||||
|
server: server.id,
|
||||||
|
id: item && item.id,
|
||||||
|
kind: item && item.kind,
|
||||||
|
error: err.message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastId = Number(res.data.lastId)
|
||||||
|
|
||||||
|
if (Number.isFinite(lastId) && lastId > since) {
|
||||||
|
// AFTER the batch. See the header.
|
||||||
|
await db.setCursor(server.id, lastId, items.length)
|
||||||
|
since = lastId
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.data.more) break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (applied > 0) log.info('ingested', { server: server.id, events: applied, cursor: since })
|
||||||
|
|
||||||
|
return applied
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the boards: what is true right now, rather than what happened.
|
||||||
|
*
|
||||||
|
* `players.online` replaces the presence rows wholesale, because that is what a
|
||||||
|
* board is. Storing it as history is the mistake the wire's `type` field exists
|
||||||
|
* to prevent, and it would be a poor return for the sidecar's trouble to make it
|
||||||
|
* here after it went out of its way not to make it there.
|
||||||
|
*/
|
||||||
|
async function applyBoards(serverId, boards) {
|
||||||
|
const presence = boards && boards['players.online']
|
||||||
|
|
||||||
|
if (presence && Array.isArray(presence.players)) {
|
||||||
|
await db.replacePresence(serverId, presence.players)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { apply, applyBoards, ingestServer, BATCH, MAX_BATCHES_PER_TICK }
|
||||||
289
server/model/events/events.db.js
Normal file
289
server/model/events/events.db.js
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
// ── SQL for the read path ─────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Writes come from one caller (`server/ingest.js`) and reads from the routers.
|
||||||
|
// They live together because they are the same tables and the invariants are
|
||||||
|
// easier to keep true when the UPDATE and the SELECT are on the same screen.
|
||||||
|
//
|
||||||
|
// Raw parameterised SQL through `core.query`, no ORM. Placeholders always —
|
||||||
|
// except for one place where a list of kinds is expanded into placeholders, and
|
||||||
|
// that expansion is checked in `events.model.js` before it ever reaches here.
|
||||||
|
|
||||||
|
const core = require('../../core')
|
||||||
|
|
||||||
|
const EVENTS = 'rust_events'
|
||||||
|
const STATS = 'rust_player_wipe_stats'
|
||||||
|
const GATHER = 'rust_gather_totals'
|
||||||
|
const PLAYERS = 'rust_players'
|
||||||
|
const WIPES = 'rust_wipes'
|
||||||
|
const PRESENCE = 'rust_presence'
|
||||||
|
const CURSOR = 'rust_ingest_cursor'
|
||||||
|
|
||||||
|
// ── The cursor ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function getCursor(serverId) {
|
||||||
|
const rows = await core.query(
|
||||||
|
`SELECT server_id AS serverId, last_event_id AS lastEventId, events_seen AS eventsSeen
|
||||||
|
FROM ${CURSOR} WHERE server_id = ?`,
|
||||||
|
[serverId],
|
||||||
|
)
|
||||||
|
return rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves a server's cursor forward, counting what it passed.
|
||||||
|
*
|
||||||
|
* **Called only after the batch it describes has been written.** The whole
|
||||||
|
* correctness of the ingest is in that ordering: if this ran first, a crash
|
||||||
|
* between the two would skip events for ever, silently, with no way to notice.
|
||||||
|
* Running it last means a crash re-reads events it has already counted at worst
|
||||||
|
* — see `ingest.js` for what makes that survivable.
|
||||||
|
*/
|
||||||
|
async function setCursor(serverId, lastEventId, seen = 0) {
|
||||||
|
await core.query(
|
||||||
|
`INSERT INTO ${CURSOR} (server_id, last_event_id, events_seen, updated_at)
|
||||||
|
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
last_event_id = VALUES(last_event_id),
|
||||||
|
events_seen = events_seen + VALUES(events_seen),
|
||||||
|
updated_at = CURRENT_TIMESTAMP`,
|
||||||
|
[serverId, lastEventId, seen],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Writes ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function insertEvent({ serverId, wipeId, kind, t, steamId, raw }) {
|
||||||
|
await core.query(
|
||||||
|
`INSERT INTO ${EVENTS} (server_id, wipe_id, kind, t, steam_id, raw)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
[serverId, wipeId || null, kind, t, steamId || null, JSON.stringify(raw)],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notes that a wipe exists, from any frame that mentions it.
|
||||||
|
*
|
||||||
|
* There is no "a wipe started" call, because the website is not there when one
|
||||||
|
* does — a wipe happens to a game server that was restarted while nobody was
|
||||||
|
* watching. A wipe is therefore created by being mentioned, and `last_seen`
|
||||||
|
* moves every time it is mentioned again.
|
||||||
|
*/
|
||||||
|
async function touchWipe(serverId, wipeId, saveCreatedAt = null) {
|
||||||
|
if (!wipeId) return
|
||||||
|
|
||||||
|
await core.query(
|
||||||
|
`INSERT INTO ${WIPES} (server_id, wipe_id, save_created_at, first_seen, last_seen)
|
||||||
|
VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
last_seen = CURRENT_TIMESTAMP,
|
||||||
|
save_created_at = COALESCE(VALUES(save_created_at), save_created_at)`,
|
||||||
|
[serverId, wipeId, saveCreatedAt],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notes that a player exists and what they were last called.
|
||||||
|
*
|
||||||
|
* `name` is COALESCEd rather than overwritten so that a frame which carries no
|
||||||
|
* name — a ban by id, a tally — cannot blank out the name every other frame
|
||||||
|
* supplied.
|
||||||
|
*/
|
||||||
|
async function touchPlayer(steamId, name = null) {
|
||||||
|
if (!steamId) return
|
||||||
|
|
||||||
|
await core.query(
|
||||||
|
`INSERT INTO ${PLAYERS} (steam_id, name, first_seen, last_seen)
|
||||||
|
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
name = COALESCE(VALUES(name), name),
|
||||||
|
last_seen = CURRENT_TIMESTAMP`,
|
||||||
|
[steamId, name],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds to one player's counters for one wipe.
|
||||||
|
*
|
||||||
|
* Every column is a running total that only rises within a wipe, so this is an
|
||||||
|
* upsert that ADDS rather than sets. `deltas` names only what moved; a `+ 0` on
|
||||||
|
* everything else is what keeps the caller from having to read the row first.
|
||||||
|
*/
|
||||||
|
async function addStats({ serverId, wipeId, steamId }, deltas = {}) {
|
||||||
|
if (!serverId || !steamId) return
|
||||||
|
|
||||||
|
const cols = ['kills', 'deaths', 'suicides', 'npc_kills', 'structures', 'sessions', 'playtime_sec']
|
||||||
|
const values = {
|
||||||
|
kills: deltas.kills || 0,
|
||||||
|
deaths: deltas.deaths || 0,
|
||||||
|
suicides: deltas.suicides || 0,
|
||||||
|
npc_kills: deltas.npcKills || 0,
|
||||||
|
structures: deltas.structures || 0,
|
||||||
|
sessions: deltas.sessions || 0,
|
||||||
|
playtime_sec: deltas.playtimeSec || 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
await core.query(
|
||||||
|
`INSERT INTO ${STATS} (server_id, wipe_id, steam_id, ${cols.join(', ')}, last_seen)
|
||||||
|
VALUES (?, ?, ?, ${cols.map(() => '?').join(', ')}, CURRENT_TIMESTAMP)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
${cols.map((c) => `${c} = ${c} + VALUES(${c})`).join(',\n ')},
|
||||||
|
last_seen = CURRENT_TIMESTAMP`,
|
||||||
|
[serverId, wipeId || '', steamId, ...cols.map((c) => values[c])],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addGathered({ serverId, wipeId, steamId }, resource, amount) {
|
||||||
|
if (!serverId || !steamId || !resource || !(amount > 0)) return
|
||||||
|
|
||||||
|
await core.query(
|
||||||
|
`INSERT INTO ${GATHER} (server_id, wipe_id, steam_id, resource, amount)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE amount = amount + VALUES(amount)`,
|
||||||
|
[serverId, wipeId || '', steamId, resource, amount],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces a server's presence rows with exactly what the board said.
|
||||||
|
*
|
||||||
|
* Two statements, delete then insert, because a board is a REPLACEMENT: a player
|
||||||
|
* who left between two boards has to disappear, and an upsert alone would leave
|
||||||
|
* them online for ever. It is not wrapped in a transaction on purpose — the
|
||||||
|
* window between the two is a fraction of a second of a page possibly showing an
|
||||||
|
* empty player list, against holding a lock on a table two routes read.
|
||||||
|
*/
|
||||||
|
async function replacePresence(serverId, players = []) {
|
||||||
|
await core.query(`DELETE FROM ${PRESENCE} WHERE server_id = ?`, [serverId])
|
||||||
|
|
||||||
|
for (const p of players) {
|
||||||
|
if (!p || !p.steamId) continue
|
||||||
|
|
||||||
|
await core.query(
|
||||||
|
`INSERT INTO ${PRESENCE} (server_id, steam_id, name, sleeping, connected_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ${p.connectedAt ? 'FROM_UNIXTIME(? / 1000)' : 'NULL'}, CURRENT_TIMESTAMP)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
name = VALUES(name), sleeping = VALUES(sleeping), updated_at = CURRENT_TIMESTAMP`,
|
||||||
|
p.connectedAt
|
||||||
|
? [serverId, p.steamId, p.name || null, p.sleeping ? 1 : 0, p.connectedAt]
|
||||||
|
: [serverId, p.steamId, p.name || null, p.sleeping ? 1 : 0],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deletes raw events older than `days`. Totals are never touched — that is the point of them. */
|
||||||
|
async function pruneEvents(days) {
|
||||||
|
if (!(days > 0)) return 0
|
||||||
|
|
||||||
|
const res = await core.query(
|
||||||
|
`DELETE FROM ${EVENTS} WHERE created_at < DATE_SUB(CURRENT_TIMESTAMP, INTERVAL ? DAY)`,
|
||||||
|
[days],
|
||||||
|
)
|
||||||
|
return (res && res.affectedRows) || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reads ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recent events, newest first, restricted to `kinds`.
|
||||||
|
*
|
||||||
|
* **`kinds` is never optional.** A default of "all kinds" is one forgotten
|
||||||
|
* argument away from publishing an IP address, so the caller is made to say it
|
||||||
|
* every time; `events.model.js` builds the list from the catalogue's allowlist
|
||||||
|
* and an empty list answers with no rows rather than with everything.
|
||||||
|
*/
|
||||||
|
async function recentEvents({ serverId, kinds, wipeId = null, limit = 50 }) {
|
||||||
|
if (!Array.isArray(kinds) || kinds.length === 0) return []
|
||||||
|
|
||||||
|
const holes = kinds.map(() => '?').join(', ')
|
||||||
|
const params = [serverId, ...kinds]
|
||||||
|
|
||||||
|
let sql = `SELECT id, server_id AS serverId, wipe_id AS wipeId, kind, t, steam_id AS steamId, raw
|
||||||
|
FROM ${EVENTS}
|
||||||
|
WHERE server_id = ? AND kind IN (${holes})`
|
||||||
|
|
||||||
|
if (wipeId) {
|
||||||
|
sql += ' AND wipe_id = ?'
|
||||||
|
params.push(wipeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ' ORDER BY id DESC LIMIT ?'
|
||||||
|
params.push(limit)
|
||||||
|
|
||||||
|
return core.query(sql, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The leaderboard for one wipe, or across every wipe when `wipeId` is null.
|
||||||
|
*
|
||||||
|
* All-time is a SUM over the per-wipe rows rather than a separate set of
|
||||||
|
* counters, which is what makes it impossible for the two to disagree — there
|
||||||
|
* is only ever one number, added up differently.
|
||||||
|
*/
|
||||||
|
async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit = 25 }) {
|
||||||
|
const column = { kills: 'kills', deaths: 'deaths', npcKills: 'npc_kills', playtime: 'playtime_sec' }[sort] || 'kills'
|
||||||
|
|
||||||
|
const params = [serverId]
|
||||||
|
let where = 's.server_id = ?'
|
||||||
|
|
||||||
|
if (wipeId) {
|
||||||
|
where += ' AND s.wipe_id = ?'
|
||||||
|
params.push(wipeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
params.push(limit)
|
||||||
|
|
||||||
|
return core.query(
|
||||||
|
`SELECT s.steam_id AS steamId,
|
||||||
|
p.name AS name,
|
||||||
|
SUM(s.kills) AS kills,
|
||||||
|
SUM(s.deaths) AS deaths,
|
||||||
|
SUM(s.npc_kills) AS npcKills,
|
||||||
|
SUM(s.structures) AS structures,
|
||||||
|
SUM(s.playtime_sec) AS playtimeSec,
|
||||||
|
MAX(s.last_seen) AS lastSeen
|
||||||
|
FROM ${STATS} s
|
||||||
|
LEFT JOIN ${PLAYERS} p ON p.steam_id = s.steam_id
|
||||||
|
WHERE ${where}
|
||||||
|
GROUP BY s.steam_id, p.name
|
||||||
|
ORDER BY SUM(s.${column}) DESC, MAX(s.last_seen) DESC
|
||||||
|
LIMIT ?`,
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listWipes(serverId) {
|
||||||
|
return core.query(
|
||||||
|
`SELECT wipe_id AS wipeId, save_created_at AS saveCreatedAt,
|
||||||
|
first_seen AS firstSeen, last_seen AS lastSeen
|
||||||
|
FROM ${WIPES}
|
||||||
|
WHERE server_id = ?
|
||||||
|
ORDER BY wipe_id DESC`,
|
||||||
|
[serverId],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function presenceFor(serverId) {
|
||||||
|
return core.query(
|
||||||
|
`SELECT steam_id AS steamId, name, sleeping, connected_at AS connectedAt
|
||||||
|
FROM ${PRESENCE}
|
||||||
|
WHERE server_id = ?
|
||||||
|
ORDER BY name ASC`,
|
||||||
|
[serverId],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getCursor,
|
||||||
|
setCursor,
|
||||||
|
insertEvent,
|
||||||
|
touchWipe,
|
||||||
|
touchPlayer,
|
||||||
|
addStats,
|
||||||
|
addGathered,
|
||||||
|
replacePresence,
|
||||||
|
pruneEvents,
|
||||||
|
recentEvents,
|
||||||
|
leaderboard,
|
||||||
|
listWipes,
|
||||||
|
presenceFor,
|
||||||
|
}
|
||||||
162
server/model/events/events.model.js
Normal file
162
server/model/events/events.model.js
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
// ── The read path's logic ─────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Everything that decides WHAT a caller gets, separated from the SQL that
|
||||||
|
// fetches it, so this file can be tested with no database and `events.db.js` has
|
||||||
|
// no branching to test.
|
||||||
|
//
|
||||||
|
// The decision that matters here is not a business rule, it is a boundary: what
|
||||||
|
// a signed-out visitor may see. Protocol 2 carries IP addresses and player
|
||||||
|
// reports, and the only thing standing between them and a public page is
|
||||||
|
// `catalogue.js`'s allowlist and the fact that **every read on this file takes an
|
||||||
|
// explicit viewer**. There is no default, because a default is what a caller
|
||||||
|
// gets when they forget — and the safe value is never the one that is easier to
|
||||||
|
// type.
|
||||||
|
|
||||||
|
const catalogue = require('../../catalogue')
|
||||||
|
const db = require('./events.db')
|
||||||
|
|
||||||
|
/** Hard ceiling on a page, whatever a caller asks for. */
|
||||||
|
const MAX_LIMIT = 200
|
||||||
|
|
||||||
|
function boundedLimit(requested, fallback = 50) {
|
||||||
|
const n = Number(requested)
|
||||||
|
if (!Number.isFinite(n) || n <= 0) return fallback
|
||||||
|
return Math.min(Math.trunc(n), MAX_LIMIT)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a `kind` query parameter into a list.
|
||||||
|
*
|
||||||
|
* Accepts `?kind=player.death` and `?kind=player.death,player.chat`, and answers
|
||||||
|
* `null` for anything empty — which means "whatever this viewer may see" rather
|
||||||
|
* than "nothing", and is then narrowed by the catalogue.
|
||||||
|
*/
|
||||||
|
function parseKinds(raw) {
|
||||||
|
if (!raw) return null
|
||||||
|
|
||||||
|
const list = String(raw)
|
||||||
|
.split(',')
|
||||||
|
.map((k) => k.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
return list.length > 0 ? list : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recent events for one server, already narrowed to what this viewer may see.
|
||||||
|
*
|
||||||
|
* **`admin` is a parameter, not a default.** A route that forgets it gets the
|
||||||
|
* public list, which is the direction it is safe to be wrong in. And a kind the
|
||||||
|
* caller asked for that they may not see is dropped silently rather than
|
||||||
|
* refused: naming it in an error would confirm the kind exists, which is a small
|
||||||
|
* thing to leak and a free one to avoid.
|
||||||
|
*/
|
||||||
|
async function recent({ serverId, admin = false, kind = null, wipeId = null, limit }) {
|
||||||
|
const kinds = catalogue.kindsFor({ admin, requested: parseKinds(kind) })
|
||||||
|
|
||||||
|
// Every requested kind was refused. Answering with an empty list is right —
|
||||||
|
// the events they asked for are, as far as they are concerned, not there.
|
||||||
|
if (kinds.length === 0) return []
|
||||||
|
|
||||||
|
const rows = await db.recentEvents({
|
||||||
|
serverId,
|
||||||
|
kinds,
|
||||||
|
wipeId,
|
||||||
|
limit: boundedLimit(limit),
|
||||||
|
})
|
||||||
|
|
||||||
|
return rows.map(shape)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One stored row as an API object.
|
||||||
|
*
|
||||||
|
* `raw` comes back from the database as text and is parsed here rather than in
|
||||||
|
* the db layer, because a row whose JSON will not parse is a reporting problem
|
||||||
|
* and not a query problem: it answers with the envelope it does know and an
|
||||||
|
* empty body, instead of failing a whole page over one bad row.
|
||||||
|
*/
|
||||||
|
function shape(row) {
|
||||||
|
let frame = {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
frame = typeof row.raw === 'string' ? JSON.parse(row.raw) : row.raw || {}
|
||||||
|
} catch {
|
||||||
|
frame = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: Number(row.id),
|
||||||
|
kind: row.kind,
|
||||||
|
t: Number(row.t),
|
||||||
|
wipeId: row.wipeId || null,
|
||||||
|
steamId: row.steamId || null,
|
||||||
|
frame,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The leaderboard for a server, per wipe or all-time.
|
||||||
|
*
|
||||||
|
* All-time is the same rows summed differently rather than a second set of
|
||||||
|
* counters, so the two can never disagree — which is the whole reason R12's
|
||||||
|
* "per-wipe detail plus all-time rollups" is one table and not two.
|
||||||
|
*/
|
||||||
|
async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit }) {
|
||||||
|
const rows = await db.leaderboard({
|
||||||
|
serverId,
|
||||||
|
wipeId,
|
||||||
|
sort,
|
||||||
|
limit: boundedLimit(limit, 25),
|
||||||
|
})
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
steamId: r.steamId,
|
||||||
|
name: r.name || null,
|
||||||
|
kills: Number(r.kills) || 0,
|
||||||
|
deaths: Number(r.deaths) || 0,
|
||||||
|
npcKills: Number(r.npcKills) || 0,
|
||||||
|
structures: Number(r.structures) || 0,
|
||||||
|
playtimeSec: Number(r.playtimeSec) || 0,
|
||||||
|
lastSeen: r.lastSeen || null,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every wipe this server has had, newest first.
|
||||||
|
*
|
||||||
|
* The list is what makes the per-wipe view navigable, and it is also the proof
|
||||||
|
* R12 asks for: a wipe that ended is still here, with its stats still attached.
|
||||||
|
*/
|
||||||
|
async function wipes(serverId) {
|
||||||
|
const rows = await db.listWipes(serverId)
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
wipeId: r.wipeId,
|
||||||
|
saveCreatedAt: r.saveCreatedAt || null,
|
||||||
|
firstSeen: r.firstSeen,
|
||||||
|
lastSeen: r.lastSeen,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who is on the server right now.
|
||||||
|
*
|
||||||
|
* Read from the presence board rather than counted from connect and disconnect
|
||||||
|
* events: the board is re-sent on every bridge connect and every minute, so it
|
||||||
|
* is right even after this module has missed something. Counting transitions
|
||||||
|
* instead would drift, and drift in exactly the direction people notice —
|
||||||
|
* players who never left.
|
||||||
|
*/
|
||||||
|
async function online(serverId) {
|
||||||
|
const rows = await db.presenceFor(serverId)
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
steamId: r.steamId,
|
||||||
|
name: r.name || null,
|
||||||
|
sleeping: Boolean(r.sleeping),
|
||||||
|
connectedAt: r.connectedAt || null,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { recent, leaderboard, wipes, online, parseKinds, boundedLimit, MAX_LIMIT }
|
||||||
159
server/model/links/links.db.js
Normal file
159
server/model/links/links.db.js
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
// ── SQL, and nothing else ─────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The `.db.js` half of the pair (see `servers.db.js` for why the split earns its
|
||||||
|
// keep). Raw parameterised SQL through `core.query`, placeholders always.
|
||||||
|
|
||||||
|
const core = require('../../core')
|
||||||
|
|
||||||
|
const LINKS = 'rust_account_links'
|
||||||
|
const PLAYERS = 'rust_players'
|
||||||
|
const STATS = 'rust_player_wipe_stats'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The link for one Steam id, or undefined.
|
||||||
|
*
|
||||||
|
* Joins core's `users` for the username, because every caller that asks "who
|
||||||
|
* owns this?" wants a name rather than an integer — and the one caller that
|
||||||
|
* refuses a re-link has to be able to say *whose* it is.
|
||||||
|
*/
|
||||||
|
async function getBySteamId(steamId) {
|
||||||
|
const rows = await core.query(
|
||||||
|
`SELECT l.steam_id AS steamId, l.user_id AS userId, l.name, l.server_id AS serverId,
|
||||||
|
l.linked_at AS linkedAt, u.username
|
||||||
|
FROM ${LINKS} l
|
||||||
|
JOIN users u ON u.id = l.user_id
|
||||||
|
WHERE l.steam_id = ?`,
|
||||||
|
[steamId],
|
||||||
|
)
|
||||||
|
return rows[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every Steam account one website user holds, newest first.
|
||||||
|
*
|
||||||
|
* **It joins `rust_players` for the name the game last saw**, and that is not a
|
||||||
|
* convenience. The name on the LINK is what the player was called at the moment
|
||||||
|
* they linked, which is a Rust name and changes on a whim — so a player who has
|
||||||
|
* renamed since sees a name they no longer use, on the one page of the site that
|
||||||
|
* is about who they are. The admin panel already preferred the newer one; this
|
||||||
|
* is the same rule applied where the person themselves is reading.
|
||||||
|
*
|
||||||
|
* A LEFT JOIN, because a player can link an account and never play on it.
|
||||||
|
*/
|
||||||
|
async function listForUser(userId) {
|
||||||
|
return core.query(
|
||||||
|
`SELECT l.steam_id AS steamId, l.user_id AS userId, l.name, l.server_id AS serverId,
|
||||||
|
l.linked_at AS linkedAt, p.name AS playerName
|
||||||
|
FROM ${LINKS} l
|
||||||
|
LEFT JOIN ${PLAYERS} p ON p.steam_id = l.steam_id
|
||||||
|
WHERE l.user_id = ?
|
||||||
|
ORDER BY l.linked_at DESC`,
|
||||||
|
[userId],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a link.
|
||||||
|
*
|
||||||
|
* **A plain INSERT, never an upsert**, and that is the whole of D23 expressed in
|
||||||
|
* SQL. `ON DUPLICATE KEY UPDATE` here would silently move a Steam id from one
|
||||||
|
* website account to another — which, once phase 7 makes a link a privilege path
|
||||||
|
* and phase 13 makes it an entitlement, is an account takeover performed by
|
||||||
|
* typing a six-character code. The duplicate-key error is the refusal, and the
|
||||||
|
* controller turns it into a sentence.
|
||||||
|
*/
|
||||||
|
async function insert({ steamId, userId, name, serverId }) {
|
||||||
|
await core.query(
|
||||||
|
`INSERT INTO ${LINKS} (steam_id, user_id, name, server_id)
|
||||||
|
VALUES (?, ?, ?, ?)`,
|
||||||
|
[steamId, userId, name || null, serverId || null],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a link the caller owns.
|
||||||
|
*
|
||||||
|
* Scoped by `user_id` in the statement rather than checked before it: a delete
|
||||||
|
* that reads, decides, then writes has a gap between the read and the write, and
|
||||||
|
* this way the ownership test and the deletion are the same operation. Answers
|
||||||
|
* how many rows went, so a caller can tell "removed" from "was not yours".
|
||||||
|
*/
|
||||||
|
async function removeOwned(steamId, userId) {
|
||||||
|
const result = await core.query(
|
||||||
|
`DELETE FROM ${LINKS} WHERE steam_id = ? AND user_id = ?`,
|
||||||
|
[steamId, userId],
|
||||||
|
)
|
||||||
|
return Number(result && result.affectedRows) || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a link whoever holds it — the in-game `/unlink` path, and the staff
|
||||||
|
* unlink on the `admin.users.detail` panel (D25).
|
||||||
|
*
|
||||||
|
* Unscoped by user on purpose: neither caller is the link's owner and both have
|
||||||
|
* already established their authority another way. In game the authority is the
|
||||||
|
* Steam account itself — whoever is connected as it is who it is; on the admin
|
||||||
|
* panel it is the tier gate. Which is why the admin caller writes an
|
||||||
|
* `activity.log` entry naming the operator and this does not: it cannot tell the
|
||||||
|
* two apart, and a log line that guessed would be worse than none.
|
||||||
|
*/
|
||||||
|
async function removeBySteamId(steamId) {
|
||||||
|
const result = await core.query(`DELETE FROM ${LINKS} WHERE steam_id = ?`, [steamId])
|
||||||
|
return Number(result && result.affectedRows) || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every link one user holds, enriched with what this module knows about that
|
||||||
|
* player — for the `admin.users.detail` panel.
|
||||||
|
*
|
||||||
|
* A LEFT JOIN, because a player can link an account and never play on it. An
|
||||||
|
* operator looking at that user should see the link, not an empty panel.
|
||||||
|
*/
|
||||||
|
async function listForUserWithPlayer(userId) {
|
||||||
|
return core.query(
|
||||||
|
`SELECT l.steam_id AS steamId, l.name, l.server_id AS serverId, l.linked_at AS linkedAt,
|
||||||
|
p.name AS playerName, p.first_seen AS firstSeen, p.last_seen AS lastSeen
|
||||||
|
FROM ${LINKS} l
|
||||||
|
LEFT JOIN ${PLAYERS} p ON p.steam_id = l.steam_id
|
||||||
|
WHERE l.user_id = ?
|
||||||
|
ORDER BY l.linked_at DESC`,
|
||||||
|
[userId],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-server all-time totals for one Steam id.
|
||||||
|
*
|
||||||
|
* The same rows the public leaderboard sums, grouped by server instead of
|
||||||
|
* filtered to one — so an operator sees a player across the fleet in one read.
|
||||||
|
* All-time, deliberately: an admin looking at a user wants their history, not
|
||||||
|
* this week's.
|
||||||
|
*/
|
||||||
|
async function statsForSteamId(steamId) {
|
||||||
|
return core.query(
|
||||||
|
`SELECT s.server_id AS serverId, srv.name AS serverName,
|
||||||
|
SUM(s.kills) AS kills,
|
||||||
|
SUM(s.deaths) AS deaths,
|
||||||
|
SUM(s.npc_kills) AS npcKills,
|
||||||
|
SUM(s.structures) AS structures,
|
||||||
|
SUM(s.playtime_sec) AS playtimeSec,
|
||||||
|
MAX(s.last_seen) AS lastSeen,
|
||||||
|
COUNT(DISTINCT s.wipe_id) AS wipes
|
||||||
|
FROM ${STATS} s
|
||||||
|
LEFT JOIN rust_servers srv ON srv.id = s.server_id
|
||||||
|
WHERE s.steam_id = ?
|
||||||
|
GROUP BY s.server_id, srv.name
|
||||||
|
ORDER BY SUM(s.playtime_sec) DESC`,
|
||||||
|
[steamId],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getBySteamId,
|
||||||
|
listForUser,
|
||||||
|
listForUserWithPlayer,
|
||||||
|
insert,
|
||||||
|
removeOwned,
|
||||||
|
removeBySteamId,
|
||||||
|
statsForSteamId,
|
||||||
|
}
|
||||||
258
server/model/links/links.model.js
Normal file
258
server/model/links/links.model.js
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
// ── Who owns which Steam account ──────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// R1's identity link, site-side. The flow it sits in the middle of:
|
||||||
|
//
|
||||||
|
// 1. In game, a player types `/link`. The plugin mints a one-time code, tells
|
||||||
|
// them privately, and holds it in memory for five minutes.
|
||||||
|
// 2. On the website, the player types that code. This module asks the sidecar,
|
||||||
|
// which asks the plugin, which answers with the Steam id the code belongs
|
||||||
|
// to and drops it.
|
||||||
|
// 3. This file records the result.
|
||||||
|
//
|
||||||
|
// **The site is the author of record and the game holds nothing.** That is the
|
||||||
|
// one real difference from the UO bridge, which writes a tag onto the game
|
||||||
|
// account: there is no equivalent per-account store in Rust that survives a wipe,
|
||||||
|
// and phase 7 needs the site to be authoritative anyway — it pushes permissions
|
||||||
|
// INTO the game keyed by Steam id. A copy in the game would be a second thing to
|
||||||
|
// reconcile every wipe, for no question it could answer better.
|
||||||
|
|
||||||
|
const core = require('../../core')
|
||||||
|
const db = require('./links.db')
|
||||||
|
const servers = require('../servers/servers.model')
|
||||||
|
const sidecar = require('../../sidecarClient')
|
||||||
|
|
||||||
|
const log = core.logger('links')
|
||||||
|
|
||||||
|
/** What a link looks like to any caller. Never carries a raw code. */
|
||||||
|
function shape(row) {
|
||||||
|
if (!row) return null
|
||||||
|
return {
|
||||||
|
steamId: row.steamId,
|
||||||
|
name: row.name || null,
|
||||||
|
serverId: row.serverId || null,
|
||||||
|
linkedAt: row.linkedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Steam accounts one website user holds.
|
||||||
|
*
|
||||||
|
* The name is the one the GAME last saw, falling back to the one recorded when
|
||||||
|
* they linked — the rule the admin panel already used, applied on the page the
|
||||||
|
* player themselves reads. A browser walk found the two disagreeing: staff saw
|
||||||
|
* `Wanderer` and the player saw `Wanderer-old`, for the same person on the same
|
||||||
|
* site.
|
||||||
|
*/
|
||||||
|
async function listForUser(userId) {
|
||||||
|
return (await db.listForUser(userId)).map((row) => ({
|
||||||
|
...shape(row),
|
||||||
|
name: row.playerName || row.name || null,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when this user holds this Steam id. The ownership gate every player read uses. */
|
||||||
|
async function owns(steamId, userId) {
|
||||||
|
const row = await db.getBySteamId(steamId)
|
||||||
|
return Boolean(row && Number(row.userId) === Number(userId))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redeem a code against one server, and record the link.
|
||||||
|
*
|
||||||
|
* Answers a discriminated result rather than throwing, because every outcome
|
||||||
|
* here is a sentence somebody has to read:
|
||||||
|
*
|
||||||
|
* `{ ok: true, link }` — linked
|
||||||
|
* `{ ok: false, reason: 'rejected' }`— the game says that code is not good
|
||||||
|
* `{ ok: false, reason: 'taken', username }` — someone else holds that Steam id
|
||||||
|
* `{ ok: false, reason: 'offline' }` — the game or its sidecar did not answer
|
||||||
|
*
|
||||||
|
* **`rejected` deliberately collapses "unknown" and "expired".** The plugin
|
||||||
|
* distinguishes them and an operator reading its log can too; a stranger typing
|
||||||
|
* codes must not learn which of the two they hit, because that is the difference
|
||||||
|
* between "keep guessing" and "guess faster".
|
||||||
|
*/
|
||||||
|
async function confirmOne({ server, code, userId }) {
|
||||||
|
const result = await sidecar.confirmLink(server, code)
|
||||||
|
|
||||||
|
// The transport failed: the sidecar is unreachable, the game is not connected,
|
||||||
|
// or the reply never came. None of those is a verdict on the code, so the
|
||||||
|
// player is told to try again rather than that their code is wrong.
|
||||||
|
if (!result.ok) {
|
||||||
|
log.warn('link confirm did not reach the game', { server: server.id, status: result.status })
|
||||||
|
return { ok: false, reason: 'offline' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const frame = result.data || {}
|
||||||
|
|
||||||
|
// The plugin's own refusal. `frame.reason` is `unknown`, `expired` or
|
||||||
|
// `malformed`; it is logged and not surfaced (see the doc above).
|
||||||
|
if (frame.kind !== 'link.ok' || !frame.steamId) {
|
||||||
|
log.info('link code refused', { server: server.id, reason: frame.reason || frame.kind || 'unknown' })
|
||||||
|
return { ok: false, reason: 'rejected' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const steamId = String(frame.steamId)
|
||||||
|
const held = await db.getBySteamId(steamId)
|
||||||
|
|
||||||
|
// D23: refuse, and say whose it is. A move would transfer every permission and
|
||||||
|
// entitlement phases 7 and 13 hang off this link, on a code anybody in game
|
||||||
|
// could have run — and the player's way out is `/unlink` in game, which they
|
||||||
|
// can reach from the machine they are sitting at.
|
||||||
|
if (held) {
|
||||||
|
if (Number(held.userId) === Number(userId)) {
|
||||||
|
// Already theirs. Not an error: a player who pressed the button twice, or
|
||||||
|
// one whose code was confirmed on a request that then timed out.
|
||||||
|
return { ok: true, link: shape(held), already: true }
|
||||||
|
}
|
||||||
|
return { ok: false, reason: 'taken', username: held.username }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.insert({
|
||||||
|
steamId,
|
||||||
|
userId,
|
||||||
|
name: frame.name || null,
|
||||||
|
serverId: server.id,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
// The race the PRIMARY KEY exists for: two confirmations of the same Steam
|
||||||
|
// id, interleaved between the check above and this write. The key refuses the
|
||||||
|
// second and it becomes the same refusal, rather than a 500.
|
||||||
|
if (err && (err.code === 'ER_DUP_ENTRY' || err.errno === 1062)) {
|
||||||
|
const now = await db.getBySteamId(steamId)
|
||||||
|
if (now && Number(now.userId) === Number(userId)) {
|
||||||
|
return { ok: true, link: shape(now), already: true }
|
||||||
|
}
|
||||||
|
return { ok: false, reason: 'taken', username: now && now.username }
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
const link = shape(await db.getBySteamId(steamId))
|
||||||
|
log.info('steam account linked', { steamId, userId, server: server.id })
|
||||||
|
return { ok: true, link }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redeem a code against the fleet (D24).
|
||||||
|
*
|
||||||
|
* **A code is minted by ONE server and the player types six characters into a
|
||||||
|
* browser**, so the site cannot know which server it came from — nothing in the
|
||||||
|
* code says, and asking the player to pick would make a wrong guess
|
||||||
|
* indistinguishable from a wrong code, which is the one refusal that must not be
|
||||||
|
* ambiguous. So every enabled server is asked in turn and the first `link.ok`
|
||||||
|
* wins. The others answer `unknown` and nothing happens there: a code is only
|
||||||
|
* spent at the server that actually holds it.
|
||||||
|
*
|
||||||
|
* The loop stops early on `taken`, because that is a verdict about the Steam id
|
||||||
|
* rather than about this server — asking the rest of the fleet would produce the
|
||||||
|
* same answer more slowly.
|
||||||
|
*
|
||||||
|
* **"Every reachable server refused" is not the same answer as "a server was
|
||||||
|
* unreachable"**, and collapsing them is how a player who linked on the one
|
||||||
|
* server that is down gets told their code is wrong. `unsure` is that case, and
|
||||||
|
* the sentence it earns says to try again rather than to run `/link` again.
|
||||||
|
*/
|
||||||
|
async function redeem({ code, userId }) {
|
||||||
|
const fleet = await servers.listForPolling()
|
||||||
|
|
||||||
|
if (fleet.length === 0) return { ok: false, reason: 'no-servers' }
|
||||||
|
|
||||||
|
let refused = 0
|
||||||
|
let unreachable = 0
|
||||||
|
|
||||||
|
for (const server of fleet) {
|
||||||
|
// Sequential, deliberately. In parallel every server would be asked even
|
||||||
|
// after one had already answered, and a code spent on the right server would
|
||||||
|
// still be travelling to five others — for a fleet of six and a five-minute
|
||||||
|
// TTL, there is nothing to win by racing them.
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
const result = await confirmOne({ server, code, userId })
|
||||||
|
|
||||||
|
if (result.ok || result.reason === 'taken') return result
|
||||||
|
|
||||||
|
if (result.reason === 'offline') unreachable += 1
|
||||||
|
else refused += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (refused === 0) return { ok: false, reason: 'offline' }
|
||||||
|
if (unreachable > 0) return { ok: false, reason: 'unsure' }
|
||||||
|
|
||||||
|
return { ok: false, reason: 'rejected' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove a link the caller owns. False when they did not hold it. */
|
||||||
|
async function unlinkOwned(steamId, userId) {
|
||||||
|
return (await db.removeOwned(steamId, userId)) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a link whoever holds it.
|
||||||
|
*
|
||||||
|
* Two callers, both of which have already established their authority and
|
||||||
|
* neither of which is the link's owner: ingest applying an in-game `/unlink`
|
||||||
|
* (the authority is the Steam account — whoever is connected as it is who it
|
||||||
|
* is), and a staff unlink from the `admin.users.detail` panel (D25).
|
||||||
|
*
|
||||||
|
* It logs nothing about who asked, because the two callers record that
|
||||||
|
* differently: the admin one writes an `activity.log` entry naming the operator,
|
||||||
|
* and the game one has no operator to name.
|
||||||
|
*/
|
||||||
|
async function unlinkAnyOwner(steamId) {
|
||||||
|
return (await db.removeBySteamId(steamId)) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a link because the player asked in game.
|
||||||
|
*
|
||||||
|
* Called from ingest, off an `account.unlinked` event.
|
||||||
|
*/
|
||||||
|
async function unlinkFromGame(steamId) {
|
||||||
|
const removed = await unlinkAnyOwner(steamId)
|
||||||
|
if (removed) log.info('steam account unlinked in game', { steamId })
|
||||||
|
return removed
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The admin panel's read: every link this user holds, with per-server totals. */
|
||||||
|
async function forAdmin(userId) {
|
||||||
|
const links = await db.listForUserWithPlayer(userId)
|
||||||
|
|
||||||
|
return Promise.all(
|
||||||
|
links.map(async (row) => ({
|
||||||
|
steamId: row.steamId,
|
||||||
|
// The name on the LINK is what they were called when they linked; the one
|
||||||
|
// on `rust_players` is what the game last saw. They differ the moment
|
||||||
|
// somebody renames, and the newer one is the useful one to show.
|
||||||
|
name: row.playerName || row.name || null,
|
||||||
|
linkedName: row.name || null,
|
||||||
|
serverId: row.serverId || null,
|
||||||
|
linkedAt: row.linkedAt,
|
||||||
|
firstSeen: row.firstSeen || null,
|
||||||
|
lastSeen: row.lastSeen || null,
|
||||||
|
servers: (await db.statsForSteamId(row.steamId)).map((s) => ({
|
||||||
|
serverId: s.serverId,
|
||||||
|
serverName: s.serverName || s.serverId,
|
||||||
|
kills: Number(s.kills) || 0,
|
||||||
|
deaths: Number(s.deaths) || 0,
|
||||||
|
npcKills: Number(s.npcKills) || 0,
|
||||||
|
structures: Number(s.structures) || 0,
|
||||||
|
playtimeSec: Number(s.playtimeSec) || 0,
|
||||||
|
wipes: Number(s.wipes) || 0,
|
||||||
|
lastSeen: s.lastSeen || null,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
shape,
|
||||||
|
listForUser,
|
||||||
|
owns,
|
||||||
|
confirmOne,
|
||||||
|
redeem,
|
||||||
|
unlinkOwned,
|
||||||
|
unlinkAnyOwner,
|
||||||
|
unlinkFromGame,
|
||||||
|
forAdmin,
|
||||||
|
}
|
||||||
459
server/model/permissions/permissions.db.js
Normal file
459
server/model/permissions/permissions.db.js
Normal file
@@ -0,0 +1,459 @@
|
|||||||
|
// ── SQL for the permission mirror, and nothing else ───────────────────────
|
||||||
|
//
|
||||||
|
// The tables this file reads are described at length in `db/schema.sql`; what
|
||||||
|
// matters here is which of them is authoritative for what, because four of the
|
||||||
|
// eight look similar and answer completely different questions:
|
||||||
|
//
|
||||||
|
// AUTHORED `rust_perm_groups`, `..._group_permissions`, `..._group_members`,
|
||||||
|
// `rust_perm_grants` — what an operator (and later an event) says
|
||||||
|
// should be true. Keyed by WEBSITE USER (D28).
|
||||||
|
// PUSHED `rust_perm_pushed` — what this site has confirmed into one game's
|
||||||
|
// store. Keyed by STEAM ID, because it records what is in the game
|
||||||
|
// and the game has never heard of a website account.
|
||||||
|
// FOUND `rust_perm_drift` — what a sync found that the site did not
|
||||||
|
// author. Replaced whole by each report: it is the current
|
||||||
|
// difference, not a history of differences.
|
||||||
|
// INSTRUCTED `rust_perm_revocations` — remove this, even though we never put
|
||||||
|
// it there. The only way to act on drift, since a foreign grant
|
||||||
|
// often names a Steam id no website account holds.
|
||||||
|
//
|
||||||
|
// Raw parameterised SQL through `core.query`, no ORM, like every other `.db.js`
|
||||||
|
// here. Bulk writes are batched into one statement with a generated placeholder
|
||||||
|
// list rather than looped, because a fleet-wide sync writes hundreds of rows and
|
||||||
|
// a round trip each is how a boot tick becomes a second long.
|
||||||
|
|
||||||
|
const core = require('../../core')
|
||||||
|
|
||||||
|
const GROUPS = 'rust_perm_groups'
|
||||||
|
const GROUP_PERMISSIONS = 'rust_perm_group_permissions'
|
||||||
|
const GROUP_MEMBERS = 'rust_perm_group_members'
|
||||||
|
const GRANTS = 'rust_perm_grants'
|
||||||
|
const PUSHED = 'rust_perm_pushed'
|
||||||
|
const DRIFT = 'rust_perm_drift'
|
||||||
|
const REVOCATIONS = 'rust_perm_revocations'
|
||||||
|
const SYNC = 'rust_perm_sync'
|
||||||
|
const CATALOGUE = 'rust_perm_catalogue'
|
||||||
|
const LINKS = 'rust_account_links'
|
||||||
|
const SERVERS = 'rust_servers'
|
||||||
|
|
||||||
|
/** `(?,?,?),(?,?,?)` for `rows.length` rows of `width` columns. */
|
||||||
|
function placeholders(rows, width) {
|
||||||
|
return rows.map(() => `(${new Array(width).fill('?').join(',')})`).join(',')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the authored set ----
|
||||||
|
|
||||||
|
async function listGroups() {
|
||||||
|
return core.query(
|
||||||
|
`SELECT name, title, \`rank\`, scope, created_at AS createdAt, updated_at AS updatedAt
|
||||||
|
FROM ${GROUPS}
|
||||||
|
ORDER BY \`rank\` DESC, name ASC`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getGroup(name) {
|
||||||
|
const rows = await core.query(
|
||||||
|
`SELECT name, title, \`rank\`, scope FROM ${GROUPS} WHERE name = ?`,
|
||||||
|
[name],
|
||||||
|
)
|
||||||
|
|
||||||
|
return rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create or update one group.
|
||||||
|
*
|
||||||
|
* `ON DUPLICATE KEY UPDATE` rather than a check-then-write: two admins on the
|
||||||
|
* same screen is not a race worth losing a title over, and the row's identity is
|
||||||
|
* its name either way.
|
||||||
|
*/
|
||||||
|
async function upsertGroup({ name, title, rank, scope }) {
|
||||||
|
await core.query(
|
||||||
|
`INSERT INTO ${GROUPS} (name, title, \`rank\`, scope)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE title = VALUES(title), \`rank\` = VALUES(\`rank\`),
|
||||||
|
scope = VALUES(scope), updated_at = CURRENT_TIMESTAMP`,
|
||||||
|
[name, title, rank, scope],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteGroup(name) {
|
||||||
|
const result = await core.query(`DELETE FROM ${GROUPS} WHERE name = ?`, [name])
|
||||||
|
return Number(result.affectedRows || 0) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listGroupPermissions() {
|
||||||
|
return core.query(
|
||||||
|
`SELECT group_name AS groupName, permission FROM ${GROUP_PERMISSIONS} ORDER BY permission ASC`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replace a group's permission list whole. The form edits a list, so the write is a list. */
|
||||||
|
async function setGroupPermissions(name, permissions) {
|
||||||
|
await core.query(`DELETE FROM ${GROUP_PERMISSIONS} WHERE group_name = ?`, [name])
|
||||||
|
|
||||||
|
if (!permissions.length) return
|
||||||
|
|
||||||
|
await core.query(
|
||||||
|
`INSERT INTO ${GROUP_PERMISSIONS} (group_name, permission)
|
||||||
|
VALUES ${placeholders(permissions, 2)}`,
|
||||||
|
permissions.flatMap((permission) => [name, permission]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every membership, with the member's Steam accounts joined on.
|
||||||
|
*
|
||||||
|
* One query rather than a membership read plus a link read per member: the admin
|
||||||
|
* screen renders both together and the push needs both together, and a fleet's
|
||||||
|
* worth of members is one round trip either way.
|
||||||
|
*/
|
||||||
|
async function listGroupMembers() {
|
||||||
|
return core.query(
|
||||||
|
`SELECT m.group_name AS groupName, m.user_id AS userId, m.added_at AS addedAt,
|
||||||
|
u.username, l.steam_id AS steamId, p.name AS playerName
|
||||||
|
FROM ${GROUP_MEMBERS} m
|
||||||
|
JOIN users u ON u.id = m.user_id
|
||||||
|
LEFT JOIN ${LINKS} l ON l.user_id = m.user_id
|
||||||
|
LEFT JOIN rust_players p ON p.steam_id = l.steam_id
|
||||||
|
ORDER BY m.group_name ASC, u.username ASC`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addGroupMember(groupName, userId, addedBy) {
|
||||||
|
await core.query(
|
||||||
|
`INSERT IGNORE INTO ${GROUP_MEMBERS} (group_name, user_id, added_by) VALUES (?, ?, ?)`,
|
||||||
|
[groupName, userId, addedBy],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeGroupMember(groupName, userId) {
|
||||||
|
const result = await core.query(
|
||||||
|
`DELETE FROM ${GROUP_MEMBERS} WHERE group_name = ? AND user_id = ?`,
|
||||||
|
[groupName, userId],
|
||||||
|
)
|
||||||
|
|
||||||
|
return Number(result.affectedRows || 0) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every direct grant, with the holder's accounts joined on.
|
||||||
|
*
|
||||||
|
* `username` is on the row because a grant with no linked Steam account still
|
||||||
|
* has to be listable and nameable — that state is the one the admin screen most
|
||||||
|
* needs to show, since it looks exactly like a working grant from every other
|
||||||
|
* angle and reaches nobody.
|
||||||
|
*/
|
||||||
|
async function listGrants({ userId = null } = {}) {
|
||||||
|
return core.query(
|
||||||
|
`SELECT g.id, g.user_id AS userId, g.permission, g.scope, g.source, g.note,
|
||||||
|
g.granted_at AS grantedAt, u.username,
|
||||||
|
l.steam_id AS steamId, p.name AS playerName
|
||||||
|
FROM ${GRANTS} g
|
||||||
|
JOIN users u ON u.id = g.user_id
|
||||||
|
LEFT JOIN ${LINKS} l ON l.user_id = g.user_id
|
||||||
|
LEFT JOIN rust_players p ON p.steam_id = l.steam_id
|
||||||
|
${userId === null ? '' : 'WHERE g.user_id = ?'}
|
||||||
|
ORDER BY u.username ASC, g.permission ASC`,
|
||||||
|
userId === null ? [] : [userId],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getGrant(id) {
|
||||||
|
const rows = await core.query(
|
||||||
|
`SELECT id, user_id AS userId, permission, scope, source FROM ${GRANTS} WHERE id = ?`,
|
||||||
|
[id],
|
||||||
|
)
|
||||||
|
|
||||||
|
return rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a grant, or leave the one that is already there alone.
|
||||||
|
*
|
||||||
|
* `INSERT IGNORE` against the unique key, and the return says which happened —
|
||||||
|
* the controller needs to tell "granted" from "they already had it" to write an
|
||||||
|
* honest activity row.
|
||||||
|
*/
|
||||||
|
async function insertGrant({ userId, permission, scope, source, note, grantedBy }) {
|
||||||
|
const result = await core.query(
|
||||||
|
`INSERT IGNORE INTO ${GRANTS} (user_id, permission, scope, source, note, granted_by)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
[userId, permission, scope, source, note, grantedBy],
|
||||||
|
)
|
||||||
|
|
||||||
|
return { inserted: Number(result.affectedRows || 0) > 0, id: result.insertId }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteGrant(id) {
|
||||||
|
const result = await core.query(`DELETE FROM ${GRANTS} WHERE id = ?`, [id])
|
||||||
|
return Number(result.affectedRows || 0) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One website account by name, for the authoring form.
|
||||||
|
*
|
||||||
|
* A form that made an operator type a numeric user id would be a form nobody
|
||||||
|
* could use, and the alternative — calling core's own admin user search from the
|
||||||
|
* client — would bind this module to the shape of a response the contract does
|
||||||
|
* not cover. Reading the `users` table is already what every join in this file
|
||||||
|
* does.
|
||||||
|
*
|
||||||
|
* Case-insensitive because the column's collation is: core stores usernames in a
|
||||||
|
* `_ci` collation and an exact-case lookup would refuse a name the site itself
|
||||||
|
* considers the same one.
|
||||||
|
*/
|
||||||
|
async function findUserByUsername(username) {
|
||||||
|
const rows = await core.query(`SELECT id, username FROM users WHERE username = ? LIMIT 1`, [username])
|
||||||
|
return rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which website user holds which Steam account. The join that turns an authored row into a push. */
|
||||||
|
async function listLinks() {
|
||||||
|
return core.query(`SELECT user_id AS userId, steam_id AS steamId FROM ${LINKS}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- what is actually out there ----
|
||||||
|
|
||||||
|
async function listPushed(serverId) {
|
||||||
|
return core.query(
|
||||||
|
`SELECT kind, subject, object FROM ${PUSHED} WHERE server_id = ?`,
|
||||||
|
[serverId],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addPushed(serverId, rows) {
|
||||||
|
if (!rows.length) return
|
||||||
|
|
||||||
|
await core.query(
|
||||||
|
`INSERT IGNORE INTO ${PUSHED} (server_id, kind, subject, object)
|
||||||
|
VALUES ${placeholders(rows, 4)}`,
|
||||||
|
rows.flatMap((row) => [serverId, row.kind, row.subject, row.object]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removePushed(serverId, rows) {
|
||||||
|
for (const row of rows) {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await core.query(
|
||||||
|
`DELETE FROM ${PUSHED} WHERE server_id = ? AND kind = ? AND subject = ? AND object = ?`,
|
||||||
|
[serverId, row.kind, row.subject, row.object],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace one server's drift list with what the latest report found.
|
||||||
|
*
|
||||||
|
* Whole, rather than merged, and `first_seen` survives through the
|
||||||
|
* `ON DUPLICATE KEY UPDATE` — so "this has been here since Tuesday" is still
|
||||||
|
* answerable while "somebody has since undone it" removes the row.
|
||||||
|
*/
|
||||||
|
async function replaceDrift(serverId, rows) {
|
||||||
|
if (!rows.length) {
|
||||||
|
await core.query(`DELETE FROM ${DRIFT} WHERE server_id = ?`, [serverId])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await core.query(
|
||||||
|
`INSERT INTO ${DRIFT} (server_id, kind, subject, object)
|
||||||
|
VALUES ${placeholders(rows, 4)}
|
||||||
|
ON DUPLICATE KEY UPDATE last_seen = CURRENT_TIMESTAMP`,
|
||||||
|
rows.flatMap((row) => [serverId, row.kind, row.subject, row.object]),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Anything this report did NOT name is gone from the game, so it goes from
|
||||||
|
// here. Named explicitly rather than swept by timestamp: two syncs a second
|
||||||
|
// apart would make a timestamp window either delete live rows or keep dead
|
||||||
|
// ones, depending on the clock.
|
||||||
|
await core.query(
|
||||||
|
`DELETE FROM ${DRIFT}
|
||||||
|
WHERE server_id = ?
|
||||||
|
AND (kind, subject, object) NOT IN (${placeholders(rows, 3)})`,
|
||||||
|
[serverId, ...rows.flatMap((row) => [row.kind, row.subject, row.object])],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listDrift() {
|
||||||
|
return core.query(
|
||||||
|
`SELECT d.id, d.server_id AS serverId, d.kind, d.subject, d.object,
|
||||||
|
d.first_seen AS firstSeen, d.last_seen AS lastSeen,
|
||||||
|
l.user_id AS userId, u.username, p.name AS playerName
|
||||||
|
FROM ${DRIFT} d
|
||||||
|
LEFT JOIN ${LINKS} l ON l.steam_id = d.subject
|
||||||
|
LEFT JOIN users u ON u.id = l.user_id
|
||||||
|
LEFT JOIN rust_players p ON p.steam_id = d.subject
|
||||||
|
ORDER BY d.server_id ASC, d.kind ASC, d.subject ASC`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDrift(id) {
|
||||||
|
const rows = await core.query(
|
||||||
|
`SELECT id, server_id AS serverId, kind, subject, object FROM ${DRIFT} WHERE id = ?`,
|
||||||
|
[id],
|
||||||
|
)
|
||||||
|
|
||||||
|
return rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteDrift(id) {
|
||||||
|
await core.query(`DELETE FROM ${DRIFT} WHERE id = ?`, [id])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queueRevocation({ serverId, kind, subject, object, requestedBy }) {
|
||||||
|
await core.query(
|
||||||
|
`INSERT IGNORE INTO ${REVOCATIONS} (server_id, kind, subject, object, requested_by)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
[serverId, kind, subject, object, requestedBy],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listRevocations(serverId) {
|
||||||
|
return core.query(
|
||||||
|
`SELECT id, kind, subject, object FROM ${REVOCATIONS} WHERE server_id = ?`,
|
||||||
|
[serverId],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteRevocations(ids) {
|
||||||
|
if (!ids.length) return
|
||||||
|
|
||||||
|
await core.query(
|
||||||
|
`DELETE FROM ${REVOCATIONS} WHERE id IN (${ids.map(() => '?').join(',')})`,
|
||||||
|
ids,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the state of the mirror ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One sync row per configured server, created on demand.
|
||||||
|
*
|
||||||
|
* A server added today has no row and must not therefore be skipped for ever, so
|
||||||
|
* the read inserts what is missing rather than the writer remembering to.
|
||||||
|
*/
|
||||||
|
async function ensureSyncRows() {
|
||||||
|
await core.query(
|
||||||
|
`INSERT IGNORE INTO ${SYNC} (server_id) SELECT id FROM ${SERVERS}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listSync() {
|
||||||
|
return core.query(
|
||||||
|
`SELECT s.server_id AS serverId, s.state, s.dirty, s.desired_hash AS desiredHash,
|
||||||
|
s.synced_hash AS syncedHash, s.boot_id AS bootId, s.wipe_id AS wipeId,
|
||||||
|
s.last_attempt_at AS lastAttemptAt, s.last_ok_at AS lastOkAt,
|
||||||
|
s.report, s.error
|
||||||
|
FROM ${SYNC} s
|
||||||
|
ORDER BY s.server_id ASC`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark servers as needing a sync.
|
||||||
|
*
|
||||||
|
* `scope` is a server id or `*`; a fleet-wide change dirties every row, which is
|
||||||
|
* right: the set each server should hold has changed even if only one of them
|
||||||
|
* will notice a difference.
|
||||||
|
*/
|
||||||
|
async function markDirty(scope) {
|
||||||
|
if (!scope || scope === '*') {
|
||||||
|
await core.query(`UPDATE ${SYNC} SET dirty = 1, updated_at = CURRENT_TIMESTAMP`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await core.query(
|
||||||
|
`UPDATE ${SYNC} SET dirty = 1, updated_at = CURRENT_TIMESTAMP WHERE server_id = ?`,
|
||||||
|
[scope],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record the outcome of one attempt.
|
||||||
|
*
|
||||||
|
* **`dirty` is cleared unconditionally, and that is safe because it is an
|
||||||
|
* optimisation rather than the truth.** Something may well have changed the
|
||||||
|
* authored set while this sync was in flight, and clearing the flag would then
|
||||||
|
* lose that change — except that the loop's real condition is
|
||||||
|
* `desired_hash != synced_hash`, recomputed from the tables on every tick. The
|
||||||
|
* flag only saves a hash comparison; the hash is what cannot be wrong.
|
||||||
|
*
|
||||||
|
* `last_ok_at` moves only on success, and it is passed rather than composed into
|
||||||
|
* the SQL so the statement is the same string every time.
|
||||||
|
*/
|
||||||
|
async function putSyncResult(serverId, { state, syncedHash, desiredHash, bootId, wipeId, report, error }) {
|
||||||
|
const okAt = state === 'ok' ? new Date() : null
|
||||||
|
|
||||||
|
await core.query(
|
||||||
|
`INSERT INTO ${SYNC} (server_id, state, dirty, desired_hash, synced_hash, boot_id, wipe_id,
|
||||||
|
last_attempt_at, last_ok_at, report, error, updated_at)
|
||||||
|
VALUES (?, ?, 0, ?, ?, ?, ?, NOW(), ?, ?, ?, NOW())
|
||||||
|
ON DUPLICATE KEY UPDATE state = VALUES(state), dirty = 0,
|
||||||
|
desired_hash = VALUES(desired_hash),
|
||||||
|
synced_hash = VALUES(synced_hash),
|
||||||
|
boot_id = VALUES(boot_id), wipe_id = VALUES(wipe_id),
|
||||||
|
last_attempt_at = NOW(),
|
||||||
|
last_ok_at = COALESCE(VALUES(last_ok_at), last_ok_at),
|
||||||
|
report = VALUES(report), error = VALUES(error),
|
||||||
|
updated_at = NOW()`,
|
||||||
|
[serverId, state, desiredHash, syncedHash, bootId, wipeId, okAt, report, error],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the option source ----
|
||||||
|
|
||||||
|
async function putCatalogue(serverId, permissions) {
|
||||||
|
await core.query(`DELETE FROM ${CATALOGUE} WHERE server_id = ?`, [serverId])
|
||||||
|
|
||||||
|
if (!permissions.length) return
|
||||||
|
|
||||||
|
await core.query(
|
||||||
|
`INSERT IGNORE INTO ${CATALOGUE} (server_id, permission)
|
||||||
|
VALUES ${placeholders(permissions, 2)}`,
|
||||||
|
permissions.flatMap((permission) => [serverId, permission]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listCatalogue() {
|
||||||
|
return core.query(
|
||||||
|
`SELECT server_id AS serverId, permission FROM ${CATALOGUE} ORDER BY permission ASC`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
GROUPS,
|
||||||
|
GRANTS,
|
||||||
|
PUSHED,
|
||||||
|
DRIFT,
|
||||||
|
listGroups,
|
||||||
|
getGroup,
|
||||||
|
upsertGroup,
|
||||||
|
deleteGroup,
|
||||||
|
listGroupPermissions,
|
||||||
|
setGroupPermissions,
|
||||||
|
listGroupMembers,
|
||||||
|
addGroupMember,
|
||||||
|
removeGroupMember,
|
||||||
|
listGrants,
|
||||||
|
getGrant,
|
||||||
|
insertGrant,
|
||||||
|
deleteGrant,
|
||||||
|
findUserByUsername,
|
||||||
|
listLinks,
|
||||||
|
listPushed,
|
||||||
|
addPushed,
|
||||||
|
removePushed,
|
||||||
|
replaceDrift,
|
||||||
|
listDrift,
|
||||||
|
getDrift,
|
||||||
|
deleteDrift,
|
||||||
|
queueRevocation,
|
||||||
|
listRevocations,
|
||||||
|
deleteRevocations,
|
||||||
|
ensureSyncRows,
|
||||||
|
listSync,
|
||||||
|
markDirty,
|
||||||
|
putSyncResult,
|
||||||
|
putCatalogue,
|
||||||
|
listCatalogue,
|
||||||
|
}
|
||||||
356
server/model/permissions/permissions.model.js
Normal file
356
server/model/permissions/permissions.model.js
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
// ── The authored set, and what it means for one server ────────────────────
|
||||||
|
//
|
||||||
|
// This file turns "what an operator wrote on the website" into "what one game
|
||||||
|
// server's store should contain", which is where four of phase 7's decisions
|
||||||
|
// actually live:
|
||||||
|
//
|
||||||
|
// D28 a grant is authored against a WEBSITE USER and resolved to every Steam
|
||||||
|
// id they have linked, here, at the moment of the push.
|
||||||
|
// D29 every authored row carries a scope — one server, or `*` for the fleet —
|
||||||
|
// and a server sees only what names it.
|
||||||
|
// D30 groups travel as groups. Membership is a separate wire fact from the
|
||||||
|
// permissions the group carries, because the game stores them separately
|
||||||
|
// and one of the two can fail on its own (§12.2 rule 4).
|
||||||
|
// D31 the difference between the desired set and what this site has already
|
||||||
|
// pushed is what gets retired. Anything else in the store is drift, and
|
||||||
|
// drift is reported rather than undone.
|
||||||
|
//
|
||||||
|
// Nothing here talks to a sidecar — `permSync.js` does that. The split is the
|
||||||
|
// usual one and earns its keep twice over here: the whole of the interesting
|
||||||
|
// logic is a pure function of four tables, so it is tested without a game, a
|
||||||
|
// sidecar, or a database.
|
||||||
|
|
||||||
|
const crypto = require('node:crypto')
|
||||||
|
|
||||||
|
const db = require('./permissions.db')
|
||||||
|
|
||||||
|
/** A scope that means every server. Stored, rather than null, so the column never needs a coalesce. */
|
||||||
|
const FLEET = '*'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permission and group names, as both frameworks store them.
|
||||||
|
*
|
||||||
|
* Lowercased on the way in, because the store lowers them and a site that did
|
||||||
|
* not would author `Kits.VIP`, push it, read back `kits.vip`, and report its own
|
||||||
|
* grant as drift for ever.
|
||||||
|
*/
|
||||||
|
function normaliseName(value) {
|
||||||
|
return String(value || '').trim().toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a scope reaches a server. */
|
||||||
|
function inScope(scope, serverId) {
|
||||||
|
return scope === FLEET || scope === serverId
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything the authoring screen renders, in one read.
|
||||||
|
*
|
||||||
|
* Assembled here rather than in SQL because the shape is a tree — a group with
|
||||||
|
* its permissions and its members — and the alternative is either four round
|
||||||
|
* trips per group or one join that repeats every group row once per member.
|
||||||
|
*/
|
||||||
|
async function overview() {
|
||||||
|
const [groups, groupPermissions, members, grants, sync, drift, catalogue] = await Promise.all([
|
||||||
|
db.listGroups(),
|
||||||
|
db.listGroupPermissions(),
|
||||||
|
db.listGroupMembers(),
|
||||||
|
db.listGrants(),
|
||||||
|
db.listSync(),
|
||||||
|
db.listDrift(),
|
||||||
|
db.listCatalogue(),
|
||||||
|
])
|
||||||
|
|
||||||
|
const byGroup = new Map(groups.map((group) => [group.name, { ...group, permissions: [], members: [] }]))
|
||||||
|
|
||||||
|
for (const row of groupPermissions) {
|
||||||
|
const group = byGroup.get(row.groupName)
|
||||||
|
if (group) group.permissions.push(row.permission)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A member with two linked Steam accounts arrives as two rows from the join,
|
||||||
|
// and is one person on the screen — holding BOTH accounts, not the first one
|
||||||
|
// the join happened to return. The screen needs all of them: a membership is
|
||||||
|
// pushed per account, and it can be waiting on one while it landed on another.
|
||||||
|
const memberByKey = new Map()
|
||||||
|
|
||||||
|
for (const row of members) {
|
||||||
|
const group = byGroup.get(row.groupName)
|
||||||
|
if (!group) continue
|
||||||
|
|
||||||
|
const key = `${row.groupName}:${row.userId}`
|
||||||
|
let member = memberByKey.get(key)
|
||||||
|
|
||||||
|
if (!member) {
|
||||||
|
member = {
|
||||||
|
userId: row.userId,
|
||||||
|
username: row.username,
|
||||||
|
accounts: [],
|
||||||
|
addedAt: row.addedAt,
|
||||||
|
}
|
||||||
|
memberByKey.set(key, member)
|
||||||
|
group.members.push(member)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.steamId) member.accounts.push({ steamId: row.steamId, name: row.playerName || null })
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
groups: [...byGroup.values()],
|
||||||
|
grants: collapseGrants(grants),
|
||||||
|
servers: sync.map(shapeSync),
|
||||||
|
drift,
|
||||||
|
catalogue: catalogueByPermission(catalogue),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row per grant, not one per linked account.
|
||||||
|
*
|
||||||
|
* The join in `listGrants` multiplies a grant by the holder's accounts, which is
|
||||||
|
* what the push wants and the opposite of what a screen wants.
|
||||||
|
*/
|
||||||
|
function collapseGrants(rows) {
|
||||||
|
const byId = new Map()
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const existing = byId.get(row.id)
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
byId.set(row.id, {
|
||||||
|
id: row.id,
|
||||||
|
userId: row.userId,
|
||||||
|
username: row.username,
|
||||||
|
permission: row.permission,
|
||||||
|
scope: row.scope,
|
||||||
|
source: row.source,
|
||||||
|
note: row.note,
|
||||||
|
grantedAt: row.grantedAt,
|
||||||
|
accounts: row.steamId ? [{ steamId: row.steamId, name: row.playerName || null }] : [],
|
||||||
|
})
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.steamId) existing.accounts.push({ steamId: row.steamId, name: row.playerName || null })
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...byId.values()]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The sync row as a client reads it.
|
||||||
|
*
|
||||||
|
* `report` is stored as the JSON the game sent and parsed here rather than on the
|
||||||
|
* way in, so a report this build cannot read is a rendering problem on one
|
||||||
|
* screen instead of a write that failed.
|
||||||
|
*/
|
||||||
|
function shapeSync(row) {
|
||||||
|
let report = null
|
||||||
|
|
||||||
|
if (row.report) {
|
||||||
|
try {
|
||||||
|
report = JSON.parse(row.report)
|
||||||
|
} catch {
|
||||||
|
report = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
serverId: row.serverId,
|
||||||
|
state: row.state,
|
||||||
|
dirty: Boolean(row.dirty),
|
||||||
|
inSync: Boolean(row.desiredHash) && row.desiredHash === row.syncedHash && row.state === 'ok',
|
||||||
|
lastAttemptAt: row.lastAttemptAt,
|
||||||
|
lastOkAt: row.lastOkAt,
|
||||||
|
error: row.error || null,
|
||||||
|
report,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which servers know each permission name — the form's option source, and its warning label. */
|
||||||
|
function catalogueByPermission(rows) {
|
||||||
|
const byPermission = new Map()
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!byPermission.has(row.permission)) byPermission.set(row.permission, [])
|
||||||
|
byPermission.get(row.permission).push(row.serverId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...byPermission.entries()]
|
||||||
|
.map(([permission, servers]) => ({ permission, servers }))
|
||||||
|
.sort((a, b) => a.permission.localeCompare(b.permission))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The whole authored set, read once, in the shape the per-server build wants.
|
||||||
|
*
|
||||||
|
* Read once per sync tick rather than once per server: six servers is six
|
||||||
|
* different answers derived from one set of tables, and re-reading them per
|
||||||
|
* server is six times the queries for the same rows.
|
||||||
|
*/
|
||||||
|
async function readAuthored() {
|
||||||
|
const [groups, groupPermissions, members, grants, links] = await Promise.all([
|
||||||
|
db.listGroups(),
|
||||||
|
db.listGroupPermissions(),
|
||||||
|
db.listGroupMembers(),
|
||||||
|
db.listGrants(),
|
||||||
|
db.listLinks(),
|
||||||
|
])
|
||||||
|
|
||||||
|
const steamIdsByUser = new Map()
|
||||||
|
|
||||||
|
for (const link of links) {
|
||||||
|
if (!steamIdsByUser.has(link.userId)) steamIdsByUser.set(link.userId, [])
|
||||||
|
steamIdsByUser.get(link.userId).push(link.steamId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { groups, groupPermissions, members, grants, steamIdsByUser }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What one server's store should contain, and the rows that say so.
|
||||||
|
*
|
||||||
|
* Returns three things the caller needs together and must not compute twice:
|
||||||
|
*
|
||||||
|
* `payload` what goes on the wire
|
||||||
|
* `rows` the same set in `rust_perm_pushed`'s shape, for the diff
|
||||||
|
* `hash` a stable digest of `rows`, which is how the loop knows nothing
|
||||||
|
* has changed without asking a game server
|
||||||
|
*
|
||||||
|
* **A user with no linked Steam account contributes nothing and is not an
|
||||||
|
* error.** They are authored against perfectly well and reach nobody until they
|
||||||
|
* link — which the admin screen says out loud, because a grant that reaches
|
||||||
|
* nothing looks exactly like one that worked.
|
||||||
|
*/
|
||||||
|
function buildDesired(serverId, authored) {
|
||||||
|
const { groups, groupPermissions, members, grants, steamIdsByUser } = authored
|
||||||
|
|
||||||
|
const scopedGroups = groups.filter((group) => inScope(group.scope, serverId))
|
||||||
|
const groupNames = new Set(scopedGroups.map((group) => group.name))
|
||||||
|
|
||||||
|
const permissionsByGroup = new Map(scopedGroups.map((group) => [group.name, []]))
|
||||||
|
const membersByGroup = new Map(scopedGroups.map((group) => [group.name, []]))
|
||||||
|
const managed = new Set()
|
||||||
|
const rows = []
|
||||||
|
|
||||||
|
for (const group of scopedGroups)
|
||||||
|
rows.push({ kind: 'group', subject: group.name, object: '' })
|
||||||
|
|
||||||
|
for (const row of groupPermissions) {
|
||||||
|
if (!groupNames.has(row.groupName)) continue
|
||||||
|
|
||||||
|
const permission = normaliseName(row.permission)
|
||||||
|
permissionsByGroup.get(row.groupName).push(permission)
|
||||||
|
managed.add(permission)
|
||||||
|
rows.push({ kind: 'group-permission', subject: row.groupName, object: permission })
|
||||||
|
}
|
||||||
|
|
||||||
|
const seenMember = new Set()
|
||||||
|
|
||||||
|
for (const row of members) {
|
||||||
|
if (!groupNames.has(row.groupName)) continue
|
||||||
|
|
||||||
|
for (const steamId of steamIdsByUser.get(row.userId) || []) {
|
||||||
|
const key = `${row.groupName}:${steamId}`
|
||||||
|
if (seenMember.has(key)) continue
|
||||||
|
seenMember.add(key)
|
||||||
|
|
||||||
|
membersByGroup.get(row.groupName).push(steamId)
|
||||||
|
rows.push({ kind: 'member', subject: steamId, object: row.groupName })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissionsBySteamId = new Map()
|
||||||
|
const seenGrant = new Set()
|
||||||
|
|
||||||
|
for (const row of grants) {
|
||||||
|
if (!inScope(row.scope, serverId)) continue
|
||||||
|
|
||||||
|
const permission = normaliseName(row.permission)
|
||||||
|
|
||||||
|
// Managed whether or not it reaches anybody: the namespace is what makes a
|
||||||
|
// hand grant of this permission to somebody else show up as drift, and a
|
||||||
|
// grant whose holder has linked nothing would otherwise silently narrow it.
|
||||||
|
managed.add(permission)
|
||||||
|
|
||||||
|
// **Resolved from the link map, not from the row.** `listGrants` joins the
|
||||||
|
// links and therefore repeats a grant once per linked account, which would
|
||||||
|
// give the right answer here by accident — until somebody changes that query
|
||||||
|
// and one of a person's two accounts quietly stops being granted. The map is
|
||||||
|
// the same source the members above use, and it says what it means.
|
||||||
|
for (const steamId of steamIdsByUser.get(row.userId) || []) {
|
||||||
|
const key = `${steamId}:${permission}`
|
||||||
|
if (seenGrant.has(key)) continue
|
||||||
|
seenGrant.add(key)
|
||||||
|
|
||||||
|
if (!permissionsBySteamId.has(steamId)) permissionsBySteamId.set(steamId, [])
|
||||||
|
permissionsBySteamId.get(steamId).push(permission)
|
||||||
|
rows.push({ kind: 'grant', subject: steamId, object: permission })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
groups: scopedGroups.map((group) => ({
|
||||||
|
name: group.name,
|
||||||
|
title: group.title || group.name,
|
||||||
|
rank: group.rank,
|
||||||
|
permissions: permissionsByGroup.get(group.name),
|
||||||
|
members: membersByGroup.get(group.name),
|
||||||
|
})),
|
||||||
|
grants: [...permissionsBySteamId.entries()].map(([steamId, permissions]) => ({
|
||||||
|
steamId,
|
||||||
|
permissions,
|
||||||
|
})),
|
||||||
|
managed: [...managed].sort(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return { payload, rows, hash: hashRows(rows) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A digest of the desired set.
|
||||||
|
*
|
||||||
|
* Sorted before hashing, because the rows come out of several queries in an
|
||||||
|
* order nothing guarantees — an unsorted digest would differ between two reads
|
||||||
|
* of an unchanged set and push to every game server on every tick.
|
||||||
|
*/
|
||||||
|
function hashRows(rows) {
|
||||||
|
const canonical = rows
|
||||||
|
.map((row) => `${row.kind} | ||||||