Merge pull request 'feat: the module skeleton and every bundle seam' (#1) from feat/phase-1-skeleton into main
Reviewed-on: #1
This commit is contained in:
97
README.md
Normal file
97
README.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# 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 |
|
||||
| Player | `GET /api/v1/player/rust/servers` — the same, on the authenticated tier |
|
||||
| Admin | `GET/PUT/DELETE /api/v1/admin/rust/servers` and `POST …/:id/test` |
|
||||
| Page | `/rust/servers` |
|
||||
|
||||
Two tables, `rust_servers` (configuration) and `rust_server_state` (what each sidecar reported).
|
||||
|
||||
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.
|
||||
|
||||
## Build and check
|
||||
|
||||
```bash
|
||||
npm ci --prefix server && npm test --prefix server
|
||||
npm run check:imports --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
|
||||
```
|
||||
|
||||
## Install it into a core
|
||||
|
||||
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.
|
||||
|
||||
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).
|
||||
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.`)
|
||||
}
|
||||
57
client/src/api.js
Normal file
57
client/src/api.js
Normal file
@@ -0,0 +1,57 @@
|
||||
// ── 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'),
|
||||
}
|
||||
|
||||
// ── 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'),
|
||||
}
|
||||
|
||||
// ── 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' }),
|
||||
}
|
||||
|
||||
// Exported for the rare caller that needs the base itself — an `<img src>`, a
|
||||
// download link, an EventSource. Reach for `request` first.
|
||||
export { BASE }
|
||||
|
||||
export default { servers, playerServers, admin, BASE }
|
||||
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
|
||||
78
client/src/entry.jsx
Normal file
78
client/src/entry.jsx
Normal file
@@ -0,0 +1,78 @@
|
||||
// ── 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'
|
||||
|
||||
// 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 this page is at `/rust/servers`.
|
||||
//
|
||||
// **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 detail route is a later phase's, and it is deliberately not stubbed here: a
|
||||
// registered route that renders nothing is a 200 with a blank page, which is
|
||||
// worse than the 404 an unregistered one gives.
|
||||
registry.registerRoutes(ID, {
|
||||
public: [{ path: 'servers', element: <Servers /> }],
|
||||
})
|
||||
|
||||
// ── 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/servers' }],
|
||||
})
|
||||
|
||||
// `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}`)
|
||||
105
client/src/routes/public/Servers.jsx
Normal file
105
client/src/routes/public/Servers.jsx
Normal file
@@ -0,0 +1,105 @@
|
||||
// ── The server list ───────────────────────────────────────────────────────
|
||||
//
|
||||
// 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.** Core wraps public routes in its maintenance
|
||||
// gate and nothing else, so a page that omits the layout renders bare — no
|
||||
// header, no footer, no site chrome — which looks like a bug and is the contract
|
||||
// (§3.3). Admin and player routes are the other way round: core wraps those.
|
||||
//
|
||||
// **And pass a `shell`.** The layout is the chrome; `shell` is the body — the
|
||||
// centred column, the vertical padding, and the thing that holds the footer at
|
||||
// the bottom of the viewport. Widths are 'narrow', 'mid' and 'wide'; name a
|
||||
// width, never a class, because the classes belong to core's stylesheet.
|
||||
//
|
||||
// This is the phase-1 version of the landing page R8 calls for. It lists servers
|
||||
// and links nowhere yet — `/rust/servers/:id` is the next phase's work — so it is
|
||||
// deliberately a table and not a design.
|
||||
|
||||
import { EmptyState, ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
|
||||
import api from '../../api.js'
|
||||
|
||||
// A relative time that does not need a date library. `Intl.RelativeTimeFormat`
|
||||
// is in every browser core supports, and one fewer dependency in the chunk is
|
||||
// one fewer thing an operator ships.
|
||||
const RELATIVE = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
|
||||
|
||||
function ago(iso) {
|
||||
if (!iso) return 'never'
|
||||
const seconds = Math.round((new Date(iso).getTime() - Date.now()) / 1000)
|
||||
const [unit, size] = Math.abs(seconds) < 3600 ? ['minute', 60] : ['hour', 3600]
|
||||
return RELATIVE.format(Math.round(seconds / size), unit)
|
||||
}
|
||||
|
||||
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.
|
||||
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: '0.75rem' }}>
|
||||
{servers.map((server) => (
|
||||
<div
|
||||
key={server.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'baseline',
|
||||
gap: '1rem',
|
||||
padding: '0.75rem 0',
|
||||
borderBottom: '1px solid rgba(128,128,128,0.25)',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<strong>{server.name}</strong>
|
||||
{server.level ? <span style={{ opacity: 0.7 }}> · {server.level}</span> : null}
|
||||
<div style={{ opacity: 0.7, fontSize: '0.9em' }}>
|
||||
{/* `stale` is a first-class part of the answer rather than
|
||||
something the page infers from a timestamp. The server
|
||||
decides what counts as stale, because the server is what
|
||||
knows how often a sidecar is supposed to check in. */}
|
||||
Last reported {ago(server.updatedAt)}
|
||||
{server.stale ? ' — out of date, so it is shown as offline.' : '.'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ whiteSpace: 'nowrap' }}>
|
||||
{server.online
|
||||
? `${server.players}${server.maxPlayers ? ` / ${server.maxPlayers}` : ''} online`
|
||||
: 'Offline'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</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";'), [])
|
||||
})
|
||||
238
client/test/registration.test.js
Normal file
238
client/test/registration.test.js
Normal file
@@ -0,0 +1,238 @@
|
||||
// ── 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: {
|
||||
registerRoutes(id, byArea) {
|
||||
for (const [area, list] of Object.entries(byArea || {})) {
|
||||
for (const r of list || []) routes[area].push({ ...r, path: `${id}/${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) {
|
||||
assert.ok(r.path.startsWith(`${manifest.id}/`), `${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('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: [] },
|
||||
},
|
||||
})
|
||||
16
module.json
Normal file
16
module.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"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"]
|
||||
},
|
||||
"capabilities": ["servers"]
|
||||
}
|
||||
146
server/boot.js
Normal file
146
server/boot.js
Normal file
@@ -0,0 +1,146 @@
|
||||
// ── 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.
|
||||
//
|
||||
// ── Polling, in phase 1 ───────────────────────────────────────────────────
|
||||
//
|
||||
// This is a poll, and the live feed it will become is a later phase's work. The
|
||||
// poll is not a placeholder for it: a sidecar's store-backed reads are exactly
|
||||
// what answers while a game server is off, and the module will keep reading them
|
||||
// on an interval to notice a server that went away without saying anything.
|
||||
// What the feed adds is latency, not coverage.
|
||||
|
||||
const core = require('./core')
|
||||
|
||||
const db = require('./model/servers/servers.db')
|
||||
const servers = require('./model/servers/servers.model')
|
||||
const sidecar = require('./sidecarClient')
|
||||
|
||||
const log = core.logger('boot')
|
||||
|
||||
let refreshTimer = null
|
||||
|
||||
const REFRESH_MS = 30 * 1000
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
const board = await sidecar.serverBoard(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) {
|
||||
await db.putState({ serverId: server.id, reachable: false, online: false })
|
||||
return
|
||||
}
|
||||
|
||||
const frame = board.data
|
||||
if (!frame) {
|
||||
await db.putState({ serverId: server.id, reachable: true, online: false })
|
||||
return
|
||||
}
|
||||
|
||||
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,
|
||||
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.
|
||||
*/
|
||||
async function onBoot() {
|
||||
await refresh()
|
||||
refreshTimer = setInterval(refresh, REFRESH_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.
|
||||
if (typeof refreshTimer.unref === 'function') refreshTimer.unref()
|
||||
log.info('booted', { refreshMs: REFRESH_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() {
|
||||
if (refreshTimer) clearInterval(refreshTimer)
|
||||
refreshTimer = null
|
||||
log.info('shut down')
|
||||
}
|
||||
|
||||
module.exports = { onBoot, onShutdown, refresh, refreshOne, REFRESH_MS }
|
||||
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 },
|
||||
}
|
||||
23
server/db/purge.sql
Normal file
23
server/db/purge.sql
Normal file
@@ -0,0 +1,23 @@
|
||||
-- ── 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.
|
||||
|
||||
DROP TABLE IF EXISTS rust_server_state;
|
||||
DROP TABLE IF EXISTS rust_servers;
|
||||
99
server/db/schema.sql
Normal file
99
server/db/schema.sql
Normal file
@@ -0,0 +1,99 @@
|
||||
-- ── 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.
|
||||
--
|
||||
-- ── Two tables, and the split between them is the whole design ────────────
|
||||
--
|
||||
-- `rust_servers` is CONFIGURATION: rows an operator writes, from Admin → Rust.
|
||||
-- `rust_server_state` is OBSERVED STATE: rows this module writes from what a
|
||||
-- sidecar reported. 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
|
||||
);
|
||||
105
server/index.js
Normal file
105
server/index.js
Normal file
@@ -0,0 +1,105 @@
|
||||
// ── 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 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 },
|
||||
})
|
||||
|
||||
// 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, the slash commands and the two extension slots — 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',
|
||||
})
|
||||
}
|
||||
137
server/model/servers/servers.db.js
Normal file
137
server/model/servers/servers.db.js
Normal file
@@ -0,0 +1,137 @@
|
||||
// ── SQL, and nothing else ─────────────────────────────────────────────────
|
||||
//
|
||||
// Core's own backend is layered `router → controller → model → db`, with models
|
||||
// in pairs: a `.db.js` holding the SQL and a `.model.js` holding the logic that
|
||||
// calls it. The split earns its keep here for the same reason it does in core —
|
||||
// the file with the queries in it has no branching to test, and the file with the
|
||||
// branching in it has no database to stand up.
|
||||
//
|
||||
// Raw parameterised SQL through `core.query`, no ORM. Placeholders always.
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const SERVERS = 'rust_servers'
|
||||
const STATE = 'rust_server_state'
|
||||
|
||||
/**
|
||||
* Every configured server, in the operator's own order.
|
||||
*
|
||||
* **The encrypted token comes back on this read and is never returned to a
|
||||
* client.** Decryption happens in the model, one layer up; this file's job is to
|
||||
* fetch a column, not to decide who may see it.
|
||||
*/
|
||||
async function listServers({ enabledOnly = false } = {}) {
|
||||
return core.query(
|
||||
`SELECT id, name, sidecar_base_url AS sidecarBaseUrl, sidecar_token_enc AS sidecarTokenEnc,
|
||||
protocol, enabled, sort_order AS sortOrder, created_at AS createdAt, updated_at AS updatedAt
|
||||
FROM ${SERVERS}
|
||||
${enabledOnly ? 'WHERE enabled = 1' : ''}
|
||||
ORDER BY sort_order ASC, id ASC`,
|
||||
)
|
||||
}
|
||||
|
||||
async function getServer(id) {
|
||||
const rows = await core.query(
|
||||
`SELECT id, name, sidecar_base_url AS sidecarBaseUrl, sidecar_token_enc AS sidecarTokenEnc,
|
||||
protocol, enabled, sort_order AS sortOrder, created_at AS createdAt, updated_at AS updatedAt
|
||||
FROM ${SERVERS}
|
||||
WHERE id = ?`,
|
||||
[id],
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or replace a server row.
|
||||
*
|
||||
* **`sidecar_token_enc` is only written when a value is supplied.** An admin form
|
||||
* that shows a blank token field — which is the only thing it can show, since the
|
||||
* token is write-only — posts an empty string on every save that did not intend
|
||||
* to change it. Writing that through would erase the credential every time an
|
||||
* operator renamed a server, and the failure would present as the bridge going
|
||||
* down for no reason an hour after an unrelated edit.
|
||||
*/
|
||||
async function upsertServer({ id, name, sidecarBaseUrl, sidecarTokenEnc, protocol, enabled, sortOrder }) {
|
||||
const setToken = sidecarTokenEnc !== null && sidecarTokenEnc !== undefined
|
||||
|
||||
await core.query(
|
||||
`INSERT INTO ${SERVERS}
|
||||
(id, name, sidecar_base_url, sidecar_token_enc, protocol, enabled, sort_order, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
name = VALUES(name),
|
||||
sidecar_base_url = VALUES(sidecar_base_url),
|
||||
${setToken ? 'sidecar_token_enc = VALUES(sidecar_token_enc),' : ''}
|
||||
protocol = VALUES(protocol),
|
||||
enabled = VALUES(enabled),
|
||||
sort_order = VALUES(sort_order),
|
||||
updated_at = CURRENT_TIMESTAMP`,
|
||||
[id, name, sidecarBaseUrl, setToken ? sidecarTokenEnc : null, protocol, enabled ? 1 : 0, sortOrder],
|
||||
)
|
||||
}
|
||||
|
||||
async function deleteServer(id) {
|
||||
await core.query(`DELETE FROM ${SERVERS} WHERE id = ?`, [id])
|
||||
}
|
||||
|
||||
/** The last thing each server said about itself, keyed by server id. */
|
||||
async function listState() {
|
||||
return core.query(
|
||||
`SELECT server_id AS serverId, reachable, online, players, max_players AS maxPlayers,
|
||||
hostname, level, seed, world_size AS worldSize, boot_id AS bootId,
|
||||
save_created_at AS saveCreatedAt, protocol, updated_at AS updatedAt
|
||||
FROM ${STATE}`,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace one server's observed state.
|
||||
*
|
||||
* **`updated_at` is set explicitly, and it has to be.** MariaDB's
|
||||
* `ON UPDATE CURRENT_TIMESTAMP` fires only when an UPDATE actually CHANGES a
|
||||
* value, so an update writing the same numbers back — exactly what a quiet
|
||||
* server looks like — leaves the timestamp where it was. The row would then
|
||||
* cross the freshness window and the page would report the server offline while
|
||||
* it was up and reporting normally. That is invisible to every test and shows up
|
||||
* as a page that was right when you looked at it and wrong an hour later.
|
||||
*/
|
||||
async function putState(state) {
|
||||
await core.query(
|
||||
`INSERT INTO ${STATE}
|
||||
(server_id, reachable, online, players, max_players, hostname, level, seed,
|
||||
world_size, boot_id, save_created_at, protocol, raw, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
reachable = VALUES(reachable), online = VALUES(online), players = VALUES(players),
|
||||
max_players = VALUES(max_players), hostname = VALUES(hostname), level = VALUES(level),
|
||||
seed = VALUES(seed), world_size = VALUES(world_size), boot_id = VALUES(boot_id),
|
||||
save_created_at = VALUES(save_created_at), protocol = VALUES(protocol),
|
||||
raw = VALUES(raw), updated_at = CURRENT_TIMESTAMP`,
|
||||
[
|
||||
state.serverId,
|
||||
state.reachable ? 1 : 0,
|
||||
state.online ? 1 : 0,
|
||||
state.players || 0,
|
||||
state.maxPlayers || 0,
|
||||
state.hostname || null,
|
||||
state.level || null,
|
||||
state.seed === undefined ? null : state.seed,
|
||||
state.worldSize === undefined ? null : state.worldSize,
|
||||
state.bootId || null,
|
||||
state.saveCreatedAt || null,
|
||||
state.protocol === undefined ? null : state.protocol,
|
||||
state.raw ? JSON.stringify(state.raw) : null,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SERVERS,
|
||||
STATE,
|
||||
listServers,
|
||||
getServer,
|
||||
upsertServer,
|
||||
deleteServer,
|
||||
listState,
|
||||
putState,
|
||||
}
|
||||
139
server/model/servers/servers.model.js
Normal file
139
server/model/servers/servers.model.js
Normal file
@@ -0,0 +1,139 @@
|
||||
// ── The logic half ────────────────────────────────────────────────────────
|
||||
//
|
||||
// Shapes what the database returned into what a client should see, and holds the
|
||||
// one rule that matters most in this module: **what leaves this file is never the
|
||||
// sidecar's credential.**
|
||||
//
|
||||
// It is a separate file from the SQL so that it is testable without a database,
|
||||
// and the suite next door tests it that way.
|
||||
//
|
||||
// The other decision worth pointing at: **a module answers when the game is
|
||||
// unreachable rather than failing.** The website is the internet-facing process
|
||||
// and the game is not; a game being down, or a sidecar being mid-restart, is an
|
||||
// ordinary Tuesday. A page that renders "offline, last seen 20 minutes ago" is
|
||||
// right; a page that 500s because a socket is closed is a module that has made
|
||||
// the site's availability depend on the game's.
|
||||
|
||||
const core = require('../../core')
|
||||
const db = require('./servers.db')
|
||||
|
||||
const log = core.logger('servers')
|
||||
|
||||
// Past this, the last thing a server said stops being news and starts being
|
||||
// history. Presentation, so the number lives with the code that shapes the
|
||||
// response rather than in the client.
|
||||
const STALE_AFTER_MS = 5 * 60 * 1000
|
||||
|
||||
/**
|
||||
* A configured server with its token decrypted, for this module's own use.
|
||||
*
|
||||
* **Never hand the result of this to a controller.** It is the input to
|
||||
* `sidecarClient`, and the only shape in this module that holds a plaintext
|
||||
* secret.
|
||||
*
|
||||
* A token that will not decrypt is returned as `null` rather than throwing: the
|
||||
* usual cause is a `SECRET_ENC_KEY` that changed, and the right behaviour is a
|
||||
* server that reports itself unconfigured with a line in the log — not a module
|
||||
* that fails to boot and takes every other server down with it.
|
||||
*/
|
||||
function withToken(row) {
|
||||
if (!row) return null
|
||||
|
||||
let token = null
|
||||
if (row.sidecarTokenEnc) {
|
||||
try {
|
||||
token = core.secretBox().decrypt(row.sidecarTokenEnc)
|
||||
} catch (err) {
|
||||
log.error('could not decrypt a sidecar token', { server: row.id, error: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
return { id: row.id, name: row.name, baseUrl: row.sidecarBaseUrl, token, protocol: row.protocol }
|
||||
}
|
||||
|
||||
/** Every enabled server, with tokens, for the poller. */
|
||||
async function listForPolling() {
|
||||
const rows = await db.listServers({ enabledOnly: true })
|
||||
return rows.map(withToken)
|
||||
}
|
||||
|
||||
/**
|
||||
* The public view: every enabled server and what it last said.
|
||||
*
|
||||
* Nothing here is conditional on who is asking, which is the point of it being
|
||||
* the public shape. What a *player* or an *admin* additionally sees is added by
|
||||
* their own tier's controller, never removed by this one.
|
||||
*/
|
||||
async function listPublic(now = Date.now()) {
|
||||
const [servers, states] = await Promise.all([db.listServers({ enabledOnly: true }), db.listState()])
|
||||
const byId = new Map(states.map((s) => [s.serverId, s]))
|
||||
|
||||
return servers.map((row) => shapePublic(row, byId.get(row.id), now))
|
||||
}
|
||||
|
||||
function shapePublic(row, state, now) {
|
||||
const updatedAt = state && state.updatedAt ? new Date(state.updatedAt) : null
|
||||
const stale = !updatedAt || now - updatedAt.getTime() > STALE_AFTER_MS
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
// A stale row cannot claim a server is up. The row says what was true when it
|
||||
// was written, and nothing has written it since.
|
||||
online: Boolean(state && state.online) && !stale,
|
||||
players: stale ? 0 : Number(state && state.players) || 0,
|
||||
maxPlayers: Number(state && state.maxPlayers) || 0,
|
||||
hostname: (state && state.hostname) || null,
|
||||
level: (state && state.level) || null,
|
||||
worldSize: state && state.worldSize != null ? Number(state.worldSize) : null,
|
||||
seed: state && state.seed != null ? Number(state.seed) : null,
|
||||
updatedAt: updatedAt ? updatedAt.toISOString() : null,
|
||||
stale,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The admin view: configuration plus reachability, and **no token**.
|
||||
*
|
||||
* `hasToken` rather than the token, because the credential is write-only in the
|
||||
* API: the admin form accepts a new value and never shows the stored one. An
|
||||
* operator still needs to know whether one is set — a blank field means both
|
||||
* "unset" and "set, and not being shown you" otherwise.
|
||||
*/
|
||||
async function listForAdmin(now = Date.now()) {
|
||||
const [servers, states] = await Promise.all([db.listServers(), db.listState()])
|
||||
const byId = new Map(states.map((s) => [s.serverId, s]))
|
||||
|
||||
return servers.map((row) => {
|
||||
const state = byId.get(row.id)
|
||||
return {
|
||||
// The public shape first, so the admin-only fields below cannot be
|
||||
// overwritten by a key the public shape happens to share.
|
||||
...shapePublic(row, state, now),
|
||||
sidecarBaseUrl: row.sidecarBaseUrl,
|
||||
hasToken: Boolean(row.sidecarTokenEnc),
|
||||
protocol: Number(row.protocol),
|
||||
enabled: Boolean(row.enabled),
|
||||
sortOrder: Number(row.sortOrder),
|
||||
reachable: Boolean(state && state.reachable),
|
||||
bootId: (state && state.bootId) || null,
|
||||
sidecarProtocol: state && state.protocol != null ? Number(state.protocol) : null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Encrypt a token for storage. `null`/empty means "leave whatever is stored alone". */
|
||||
function encryptToken(token) {
|
||||
if (token === null || token === undefined || token === '') return null
|
||||
return core.secretBox().encrypt(String(token))
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
STALE_AFTER_MS,
|
||||
withToken,
|
||||
listForPolling,
|
||||
listPublic,
|
||||
listForAdmin,
|
||||
shapePublic,
|
||||
encryptToken,
|
||||
}
|
||||
1088
server/package-lock.json
generated
Normal file
1088
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
server/package.json
Normal file
24
server/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "rust-module-server",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Server half of the Rust module — routers, models and the schema fragment core loads at boot",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "node --test",
|
||||
"check:imports": "node scripts/checkImports.js",
|
||||
"swagger": "node scripts/swaggerFragment.js",
|
||||
"check:swagger": "node scripts/swaggerFragment.js --check"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"//dependencies": "There are none, and that is the shape to aim for: everything the shipped half needs arrives on ctx (MODULE_API.md 2.3) - express, express-validator, the database, the logger and the middleware are all core-owned and handed over. If you do add one, remember an operator never builds: your release CI runs npm ci --omit=dev and packs server/node_modules into the tarball, so every dependency is weight in the artifact and a package the operator now runs. scripts/checkImports.js reads this file to decide what the shipped half may resolve.",
|
||||
"devDependencies": {
|
||||
"express": "^4.19.2",
|
||||
"express-validator": "^7.1.0",
|
||||
"swagger-autogen": "^2.23.7"
|
||||
},
|
||||
"//devDependencies": "Test-only and build-only, never shipped. test/_fakes.js builds a REAL express Router and a REAL express-validator, because a fake of either would only ever test the fake - the admin router builds its validation chains at file scope, so a stubbed validator is not something it can be required with. swagger-autogen generates the OpenAPI fragment; pin it to the same major core uses, so the fragment and the spec it merges into come out of one tool."
|
||||
}
|
||||
132
server/router/admin/rust.controller.js
Normal file
132
server/router/admin/rust.controller.js
Normal file
@@ -0,0 +1,132 @@
|
||||
// ── Admin · Rust — the handlers ───────────────────────────────────────────
|
||||
//
|
||||
// The write side of the module. Three things every handler here owes:
|
||||
//
|
||||
// 1. **Never return the token.** Not in a response, not in an error, not in an
|
||||
// activity-log detail. It is accepted, encrypted and forgotten.
|
||||
// 2. **Record the change.** `core.activity.log` writes core's own admin audit
|
||||
// row. These handlers edit the credential that reaches a game host; "who
|
||||
// changed this" has no second place it is recorded.
|
||||
// 3. **Answer rather than throw.** An unhandled rejection reaches core's error
|
||||
// handler and gets core blamed for a fault in this module.
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const db = require('../../model/servers/servers.db')
|
||||
const servers = require('../../model/servers/servers.model')
|
||||
const sidecar = require('../../sidecarClient')
|
||||
|
||||
const log = core.logger('admin')
|
||||
|
||||
async function listServers(req, res) {
|
||||
try {
|
||||
res.json({ servers: await servers.listForAdmin() })
|
||||
} catch (err) {
|
||||
log.error('failed to read the server list', { error: err.message })
|
||||
res.status(500).json({ error: 'Failed to read the server list' })
|
||||
}
|
||||
}
|
||||
|
||||
async function putServer(req, res) {
|
||||
const { id } = req.params
|
||||
const { name, sidecarBaseUrl, sidecarToken, protocol, enabled, sortOrder } = req.body
|
||||
|
||||
try {
|
||||
const existing = await db.getServer(id)
|
||||
|
||||
// A NEW server with no token is a row that can never reach its sidecar, and
|
||||
// the operator will read the resulting "unreachable" as a network problem.
|
||||
// Refusing it up front costs one round trip and saves that hunt. An EXISTING
|
||||
// row is a different case: omitting the token is how you say "leave it".
|
||||
if (!existing && !sidecarToken) {
|
||||
return res.status(400).json({ error: 'A new server needs its sidecar token' })
|
||||
}
|
||||
|
||||
await db.upsertServer({
|
||||
id,
|
||||
name,
|
||||
sidecarBaseUrl,
|
||||
// `encryptToken` returns null for an empty value, and `upsertServer` reads
|
||||
// null as "do not write this column". The two halves of that rule are in
|
||||
// different files on purpose: the model decides what a blank means, the SQL
|
||||
// decides what null does, and neither has to know the other's reason.
|
||||
sidecarTokenEnc: servers.encryptToken(sidecarToken),
|
||||
protocol: protocol === undefined ? sidecar.PROTOCOL_VERSION : protocol,
|
||||
enabled: enabled === undefined ? true : enabled,
|
||||
sortOrder: sortOrder === undefined ? 0 : sortOrder,
|
||||
})
|
||||
|
||||
await core.activity.log({
|
||||
req,
|
||||
action: 'rust.server.save',
|
||||
detail: {
|
||||
server: id,
|
||||
created: !existing,
|
||||
sidecarBaseUrl,
|
||||
// Whether the credential was rotated, never the credential.
|
||||
tokenChanged: Boolean(sidecarToken),
|
||||
},
|
||||
})
|
||||
|
||||
return res.status(204).end()
|
||||
} catch (err) {
|
||||
log.error('failed to save a server', { server: id, error: err.message })
|
||||
return res.status(500).json({ error: 'Failed to save the server' })
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteServer(req, res) {
|
||||
const { id } = req.params
|
||||
|
||||
try {
|
||||
const existing = await db.getServer(id)
|
||||
if (!existing) return res.status(404).json({ error: 'No such server' })
|
||||
|
||||
await db.deleteServer(id)
|
||||
await core.activity.log({ req, action: 'rust.server.delete', detail: { server: id } })
|
||||
|
||||
return res.status(204).end()
|
||||
} catch (err) {
|
||||
log.error('failed to delete a server', { server: id, error: err.message })
|
||||
return res.status(500).json({ error: 'Failed to delete the server' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe one sidecar and report what came back.
|
||||
*
|
||||
* This is the route that tells a wrong URL from a wrong token from a mismatched
|
||||
* protocol, and that distinction is the whole reason it exists: all three present
|
||||
* to an operator as "the site says my server is offline", and each has a
|
||||
* different fix. The status string from `sidecarClient` is carried through
|
||||
* verbatim so the panel can say which.
|
||||
*/
|
||||
async function testServer(req, res) {
|
||||
const { id } = req.params
|
||||
|
||||
try {
|
||||
const row = await db.getServer(id)
|
||||
if (!row) return res.status(404).json({ error: 'No such server' })
|
||||
|
||||
const result = await sidecar.health(servers.withToken(row))
|
||||
|
||||
await core.activity.log({
|
||||
req,
|
||||
action: 'rust.server.test',
|
||||
detail: { server: id, ok: result.ok, status: result.status },
|
||||
})
|
||||
|
||||
return res.json({
|
||||
ok: result.ok,
|
||||
status: result.status,
|
||||
// `data` is the sidecar's own health document on success and the mismatch
|
||||
// detail on a 409. Both are safe to show: neither carries a credential.
|
||||
sidecar: result.data || null,
|
||||
})
|
||||
} catch (err) {
|
||||
log.error('failed to probe a sidecar', { server: id, error: err.message })
|
||||
return res.status(500).json({ error: 'Failed to probe the sidecar' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { listServers, putServer, deleteServer, testServer }
|
||||
89
server/router/admin/rust.router.js
Normal file
89
server/router/admin/rust.router.js
Normal file
@@ -0,0 +1,89 @@
|
||||
// ── Admin · Rust ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// Mounted at `/api/v1/admin/rust`. The tier's gate is already applied: `admin`
|
||||
// sits behind `noindex, isLoggedIn, requireRole('admin','editor','moderator')`.
|
||||
//
|
||||
// **That gate is broader than these routes should be.** Editing a server row
|
||||
// means editing the credential that reaches a game host, which is an
|
||||
// administrator's job and not a moderator's — so the routes that write add
|
||||
// `requireRole('admin')` on top of the tier. A module adds per-route gates over
|
||||
// the tier gate and never re-implements it; this is what adding one looks like.
|
||||
//
|
||||
// ── The token is write-only ───────────────────────────────────────────────
|
||||
//
|
||||
// `sidecarToken` is accepted and never returned. The list route reports
|
||||
// `hasToken` instead, because a blank field otherwise means both "unset" and
|
||||
// "set, and not being shown to you". An empty string on a save leaves the stored
|
||||
// value alone — an operator renaming a server must not have to re-paste a
|
||||
// credential, and a form that posts its own blank field would otherwise erase one
|
||||
// on every unrelated edit.
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const express = core.express
|
||||
const admin = require('./rust.controller')
|
||||
const { requireRole, validate } = core.middleware
|
||||
const { body, param } = core.validator
|
||||
|
||||
const adminRustRouter = express.Router()
|
||||
|
||||
adminRustRouter.get(
|
||||
'/servers',
|
||||
// #swagger.tags = ['Admin · Rust']
|
||||
// #swagger.summary = 'Every configured Rust server'
|
||||
// #swagger.description = 'The operator’s server rows with their sidecar URLs, whether a token is stored, and whether each sidecar was reachable on the last poll. The token itself is never returned.'
|
||||
/* #swagger.responses[200] = { description: 'The configured servers', content: { "application/json": { schema: { $ref: "#/components/schemas/RustAdminServerList" } } } } */
|
||||
admin.listServers,
|
||||
)
|
||||
|
||||
adminRustRouter.put(
|
||||
'/servers/:id',
|
||||
// #swagger.tags = ['Admin · Rust']
|
||||
// #swagger.summary = 'Create or update a Rust server'
|
||||
// #swagger.description = 'Writes one server row. `sidecarToken` is write-only — send it to set or rotate the credential, and omit it or send an empty string to leave the stored one untouched. The id is the slug every URL under the module carries.'
|
||||
/* #swagger.responses[204] = { description: 'Saved' } */
|
||||
/* #swagger.responses[400] = { description: 'Invalid body' } */
|
||||
requireRole('admin'),
|
||||
param('id')
|
||||
.matches(/^[a-z0-9][a-z0-9-]{0,63}$/)
|
||||
.withMessage('id must be lowercase letters, digits and hyphens'),
|
||||
body('name').isString().trim().isLength({ min: 1, max: 120 }),
|
||||
// A base URL is validated for SHAPE and not for reachability: an operator
|
||||
// configures a sidecar before installing it about half the time, and refusing
|
||||
// the row because nothing answers yet would make the obvious order of
|
||||
// operations impossible.
|
||||
body('sidecarBaseUrl').isURL({ require_tld: false, protocols: ['http', 'https'] }),
|
||||
body('sidecarToken').optional({ values: 'falsy' }).isString().isLength({ max: 512 }),
|
||||
body('protocol').optional().isInt({ min: 1, max: 1000 }).toInt(),
|
||||
body('enabled').optional().isBoolean().toBoolean(),
|
||||
body('sortOrder').optional().isInt({ min: -1000, max: 1000 }).toInt(),
|
||||
validate,
|
||||
admin.putServer,
|
||||
)
|
||||
|
||||
adminRustRouter.delete(
|
||||
'/servers/:id',
|
||||
// #swagger.tags = ['Admin · Rust']
|
||||
// #swagger.summary = 'Remove a Rust server'
|
||||
// #swagger.description = 'Deletes the server row and the observed state that hangs off it. It does not touch the sidecar or the game host — those are removed with the installer.'
|
||||
/* #swagger.responses[204] = { description: 'Deleted' } */
|
||||
requireRole('admin'),
|
||||
param('id').isString().isLength({ min: 1, max: 64 }),
|
||||
validate,
|
||||
admin.deleteServer,
|
||||
)
|
||||
|
||||
adminRustRouter.post(
|
||||
'/servers/:id/test',
|
||||
// #swagger.tags = ['Admin · Rust']
|
||||
// #swagger.summary = 'Probe a server’s sidecar'
|
||||
// #swagger.description = 'Calls the sidecar’s health endpoint with the stored credential and reports what came back — whether it answered, whether the bridge plugin is connected to it, and which protocol version it speaks. This is the one route that tells a wrong URL from a wrong token from a mismatched version.'
|
||||
/* #swagger.responses[200] = { description: 'What the sidecar said', content: { "application/json": { schema: { $ref: "#/components/schemas/RustSidecarProbe" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such server' } */
|
||||
requireRole('admin'),
|
||||
param('id').isString().isLength({ min: 1, max: 64 }),
|
||||
validate,
|
||||
admin.testServer,
|
||||
)
|
||||
|
||||
module.exports = adminRustRouter
|
||||
22
server/router/player/rust.controller.js
Normal file
22
server/router/player/rust.controller.js
Normal file
@@ -0,0 +1,22 @@
|
||||
// ── Player · Rust — the handlers ──────────────────────────────────────────
|
||||
//
|
||||
// See the router for why this tier is thin in phase 1. The one thing it must not
|
||||
// do is reshape the list itself: it calls the same model the public tier does, so
|
||||
// the two answers cannot drift while they are meant to be the same.
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const servers = require('../../model/servers/servers.model')
|
||||
|
||||
const log = core.logger('player')
|
||||
|
||||
async function listServers(req, res) {
|
||||
try {
|
||||
res.json({ servers: await servers.listPublic() })
|
||||
} catch (err) {
|
||||
log.error('failed to read the server list', { error: err.message })
|
||||
res.status(500).json({ error: 'Failed to read the server list' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { listServers }
|
||||
41
server/router/player/rust.router.js
Normal file
41
server/router/player/rust.router.js
Normal file
@@ -0,0 +1,41 @@
|
||||
// ── Player · Rust ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// Mounted at `/api/v1/player/rust`. The tier's gate is already applied: `player`
|
||||
// sits behind `noindex, requireAuth`, so every handler here has a signed-in user
|
||||
// and none of them re-implements that check.
|
||||
//
|
||||
// ── Why this tier exists in phase 1, and what it honestly holds ───────────
|
||||
//
|
||||
// R14 puts this module on all three tiers from the start, and the loader holds
|
||||
// `module.json`'s `mounts` against what is actually registered in **both**
|
||||
// directions — a declared prefix that never gets a router fails the load. So the
|
||||
// declaration and the registration land together or not at all.
|
||||
//
|
||||
// What this tier will carry is the signed-in view of a server: the viewer's own
|
||||
// linked Steam identity, their own presence, their own entitlements. None of that
|
||||
// exists yet — identity is a later phase — so the one route here answers the
|
||||
// server list as the signed-in caller sees it, which is currently the same list
|
||||
// the public tier serves.
|
||||
//
|
||||
// That is deliberately a real route and not a placeholder: it is the URL the app
|
||||
// and the SPA will call, and it starts answering correctly now rather than
|
||||
// changing address later. What it must not become is a second copy of the public
|
||||
// shape — it delegates to the same model, so the two cannot drift.
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const express = core.express
|
||||
const servers = require('./rust.controller')
|
||||
|
||||
const playerRustRouter = express.Router()
|
||||
|
||||
playerRustRouter.get(
|
||||
'/servers',
|
||||
// #swagger.tags = ['Player · Rust']
|
||||
// #swagger.summary = 'The Rust servers, for a signed-in player'
|
||||
// #swagger.description = 'The same servers the public list carries, answered on the authenticated tier. It is the address a signed-in client calls, so that per-player detail can be added here without moving it. Requires a session.'
|
||||
/* #swagger.responses[200] = { description: 'The server list', content: { "application/json": { schema: { $ref: "#/components/schemas/RustServerList" } } } } */
|
||||
servers.listServers,
|
||||
)
|
||||
|
||||
module.exports = playerRustRouter
|
||||
27
server/router/public/rust.controller.js
Normal file
27
server/router/public/rust.controller.js
Normal file
@@ -0,0 +1,27 @@
|
||||
// ── Public · Rust — the handlers ──────────────────────────────────────────
|
||||
//
|
||||
// Thin on purpose: read the request, call a model, answer. Everything worth
|
||||
// testing is in the model, which needs no express and no database to test.
|
||||
//
|
||||
// **A handler must not throw past express.** Core mounts this router inside its
|
||||
// own tier router, so an unhandled rejection here reaches core's error handler
|
||||
// and answers 500 — survivable, but it means an operator sees core blamed for a
|
||||
// fault in this module. Catch, log through `core.logger` (so the line carries the
|
||||
// module id), and answer something honest.
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const servers = require('../../model/servers/servers.model')
|
||||
|
||||
const log = core.logger('public')
|
||||
|
||||
async function listServers(req, res) {
|
||||
try {
|
||||
res.json({ servers: await servers.listPublic() })
|
||||
} catch (err) {
|
||||
log.error('failed to read the server list', { error: err.message })
|
||||
res.status(500).json({ error: 'Failed to read the server list' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { listServers }
|
||||
44
server/router/public/rust.router.js
Normal file
44
server/router/public/rust.router.js
Normal file
@@ -0,0 +1,44 @@
|
||||
// ── Public · Rust ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// Mounted at `/api/v1/public/rust` by `index.js`. One express Router, built from
|
||||
// CORE's express (`core.express`) — never from a `require('express')` of your
|
||||
// own, which would not resolve from here anyway (MODULE_API.md §7.2).
|
||||
//
|
||||
// **The tier's gate is already on.** This router sits inside core's public tier,
|
||||
// which is behind nothing by design. Per-route middleware goes on top, and
|
||||
// `siteMode` is the one worth understanding: it is what makes a route respect the
|
||||
// operator's maintenance switch. Core applies it to its own content routes and
|
||||
// deliberately does not apply it to its status endpoints, because status is
|
||||
// exactly what an operator wants visible *during* maintenance.
|
||||
//
|
||||
// The server list is content, not status — it is the module's landing page — so
|
||||
// it takes `siteMode`.
|
||||
//
|
||||
// ── About the `#swagger` comments ─────────────────────────────────────────
|
||||
//
|
||||
// They are not documentation *of* the code; they are the source the OpenAPI
|
||||
// fragment is generated from (`npm run swagger`, §2.8). swagger-autogen reads
|
||||
// them as JavaScript literals it evaluates, so a QUOTE CHARACTER inside a
|
||||
// single-quoted description ends the string early — and the failure is silent:
|
||||
// the value is truncated at that character while the generator prints success.
|
||||
// Use a typographic apostrophe (’) in prose. A backtick is fine.
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const express = core.express
|
||||
const servers = require('./rust.controller')
|
||||
const { siteMode } = core.middleware
|
||||
|
||||
const rustRouter = express.Router()
|
||||
|
||||
rustRouter.get(
|
||||
'/servers',
|
||||
// #swagger.tags = ['Public · Rust']
|
||||
// #swagger.summary = 'Every Rust server this site follows'
|
||||
// #swagger.description = 'The operator’s configured Rust servers and what each one last reported. Answers with `online: false` and `stale: true` rather than failing when a game server or its sidecar is unreachable — the site’s availability does not depend on the game’s.'
|
||||
/* #swagger.responses[200] = { description: 'The server list', content: { "application/json": { schema: { $ref: "#/components/schemas/RustServerList" } } } } */
|
||||
siteMode,
|
||||
servers.listServers,
|
||||
)
|
||||
|
||||
module.exports = rustRouter
|
||||
190
server/scripts/checkImports.js
Normal file
190
server/scripts/checkImports.js
Normal file
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env node
|
||||
// ── §5.1 — zero internal imports ───────────────────────────────────────────
|
||||
//
|
||||
// The acceptance test for the whole module contract. A module that reaches into
|
||||
// core's tree still works — right up until core moves a file — and the boundary
|
||||
// this workstream exists to build is worth exactly as much as this check is.
|
||||
//
|
||||
// MODULE_API.md §5.1 sketches it as a grep for `../../`. That is the shape of
|
||||
// the violation but not the rule, and the difference matters in both directions:
|
||||
// a grep says nothing about `require('../../../../etc/passwd')` from a deeply
|
||||
// nested file (which it catches by accident) and false-alarms on a legitimate
|
||||
// `require('../module.json')` from `server/` (which it catches wrongly). So this
|
||||
// RESOLVES each specifier against the file that wrote it and asks whether the
|
||||
// result is still inside the module root — the actual rule, stated once.
|
||||
//
|
||||
// Bare specifiers are checked too, and against a stricter list than "is it
|
||||
// installed": core hands the module express, express-validator, the database and
|
||||
// the logger on `ctx` precisely so the module never resolves them, and Node's
|
||||
// resolver cannot reach core's `node_modules` from here anyway. A bare
|
||||
// `require` that is not a Node builtin is therefore a module that will fail to
|
||||
// load on a real install, with a message about a missing package rather than
|
||||
// about the rule it broke.
|
||||
//
|
||||
// **That second check applies to SHIPPED code only.** `test/` and `scripts/`
|
||||
// never run inside core's process — the fakes in `test/_fakes.js` build a real
|
||||
// `express` router precisely so the module's routers are exercised for real —
|
||||
// so they may use devDependencies. The containment check applies everywhere,
|
||||
// because a test that reaches into core's tree is a test that passes on this
|
||||
// machine and nowhere else.
|
||||
//
|
||||
// Run over the SERVER half. The client half's equivalents are its Vite build,
|
||||
// which fails if a shared dependency resolves into node_modules, and
|
||||
// client/scripts/checkExternals.js, which asks the built chunk whether any bare
|
||||
// specifier survived.
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
// Node's own answer, not a list reconstructed from `builtinModules`. That list
|
||||
// omits `test` on Node 20 and includes it on Node 24, so a suite that requires
|
||||
// `node:test` passed locally and failed in CI on the very first run — reported
|
||||
// as the module boundary being broken, which it was not. `isBuiltin` is the
|
||||
// authoritative check and handles the `node:` prefix itself.
|
||||
const { isBuiltin } = require('module')
|
||||
|
||||
const MODULE_ROOT = path.resolve(__dirname, '..', '..')
|
||||
const SERVER_ROOT = path.join(MODULE_ROOT, 'server')
|
||||
|
||||
// Packages the SHIPPED half may resolve for itself: this package's declared
|
||||
// `dependencies`, and nothing else. Read from package.json rather than listed
|
||||
// here, so adding one is a visible, reviewable edit to the manifest that also
|
||||
// changes what CI installs and what the release tarball carries.
|
||||
//
|
||||
// Adding a dependency is a real decision. §2.7 permits a module its own, and the
|
||||
// release tarball carries `server/node_modules` because an operator never builds
|
||||
// — so every entry is weight in the artifact and a package the operator's
|
||||
// deployment now runs. Anything core already owns must come from `ctx` instead:
|
||||
// a second express is a second Router prototype, a second express-rate-limit is
|
||||
// a second store, and a limit enforced by two independent counters is not the
|
||||
// limit either of them states.
|
||||
|
||||
const SKIP_DIRS = new Set(['node_modules', 'coverage', '.git'])
|
||||
|
||||
// Directories whose contents never run inside core's process, and may therefore
|
||||
// resolve this package's devDependencies.
|
||||
const NOT_SHIPPED = [path.join(SERVER_ROOT, 'test'), path.join(SERVER_ROOT, 'scripts')]
|
||||
const isShipped = (file) => !NOT_SHIPPED.some((d) => file.startsWith(d + path.sep))
|
||||
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(SERVER_ROOT, 'package.json'), 'utf8'))
|
||||
const dependencies = new Set(Object.keys(manifest.dependencies || {}))
|
||||
const devDependencies = new Set(Object.keys(manifest.devDependencies || {}))
|
||||
|
||||
// `require('x')`, `from 'x'`, `import('x')`. Deliberately textual: parsing would
|
||||
// need a dependency, and a specifier this pattern misses is a specifier written
|
||||
// to be missed, which review catches and a stricter regexp would not.
|
||||
const SPECIFIER = /(?:require\(|from\s+|import\()\s*['"]([^'"]+)['"]/g
|
||||
|
||||
/**
|
||||
* Blank out comments and template literals before scanning.
|
||||
*
|
||||
* Not a nicety — without it this file fails on ITSELF, because the comments
|
||||
* above name `require('../../../../etc/passwd')` as an example of what to
|
||||
* catch, and index.js explains in prose why it must never `require('express')`.
|
||||
* A boundary check that cannot survive being described is a check people stop
|
||||
* writing comments around.
|
||||
*
|
||||
* A character walk rather than a regexp, because the two get in each other's
|
||||
* way: `'https://x'` contains a line-comment opener inside a string, and
|
||||
* `// don't` contains a quote inside a comment. Tracking the state is shorter
|
||||
* than the regexp that would almost handle it. Content is replaced with spaces
|
||||
* rather than removed so nothing else has to care.
|
||||
*/
|
||||
function stripCommentsAndTemplates(src) {
|
||||
let out = ''
|
||||
let i = 0
|
||||
const keep = (n) => { out += src.slice(i, i + n); i += n }
|
||||
const blank = (end) => { out += src.slice(i, end).replace(/[^\n]/g, ' '); i = end }
|
||||
while (i < src.length) {
|
||||
const two = src.slice(i, i + 2)
|
||||
if (two === '//') {
|
||||
const nl = src.indexOf('\n', i)
|
||||
blank(nl === -1 ? src.length : nl)
|
||||
} else if (two === '/*') {
|
||||
const end = src.indexOf('*/', i + 2)
|
||||
blank(end === -1 ? src.length : end + 2)
|
||||
} else if (src[i] === '"' || src[i] === "'") {
|
||||
// Strings are KEPT — they are where the specifiers live.
|
||||
const quote = src[i]
|
||||
keep(1)
|
||||
while (i < src.length && src[i] !== quote) keep(src[i] === '\\' ? 2 : 1)
|
||||
keep(1)
|
||||
} else if (src[i] === '`') {
|
||||
// Template literals are blanked: nothing may `require` a template, and a
|
||||
// template holding SQL or HTML is a rich source of false positives.
|
||||
i += 1
|
||||
out += ' '
|
||||
while (i < src.length && src[i] !== '`') {
|
||||
if (src[i] === '\\') { out += ' '; i += 2 } else { out += src[i] === '\n' ? '\n' : ' '; i += 1 }
|
||||
}
|
||||
i += 1
|
||||
out += ' '
|
||||
} else {
|
||||
keep(1)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function* walk(dir) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) {
|
||||
if (!SKIP_DIRS.has(entry.name)) yield* walk(path.join(dir, entry.name))
|
||||
} else if (/\.(js|mjs|cjs)$/.test(entry.name)) {
|
||||
yield path.join(dir, entry.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every boundary violation under `root`, resolved against `moduleRoot`.
|
||||
*
|
||||
* Exported so `test/checkImports.test.js` can point it at fixtures. A check that
|
||||
* has never been shown to fail is a check nobody knows the state of — and this
|
||||
* one guards the acceptance criterion for the whole contract.
|
||||
*/
|
||||
function scan(root, moduleRoot = MODULE_ROOT, { shipped = isShipped, deps = dependencies, dev = devDependencies } = {}) {
|
||||
const violations = []
|
||||
for (const file of walk(root)) {
|
||||
const source = stripCommentsAndTemplates(fs.readFileSync(file, 'utf8'))
|
||||
for (const [, specifier] of source.matchAll(SPECIFIER)) {
|
||||
if (specifier.startsWith('.')) {
|
||||
const resolved = path.resolve(path.dirname(file), specifier)
|
||||
if (resolved !== moduleRoot && !resolved.startsWith(moduleRoot + path.sep)) {
|
||||
violations.push({ file, specifier, why: 'escapes the module root' })
|
||||
}
|
||||
} else if (path.isAbsolute(specifier)) {
|
||||
violations.push({ file, specifier, why: 'absolute path' })
|
||||
} else {
|
||||
const pkg = specifier.startsWith('@')
|
||||
? specifier.split('/').slice(0, 2).join('/')
|
||||
: specifier.split('/')[0]
|
||||
const allowed = deps.has(pkg) || (!shipped(file) && dev.has(pkg))
|
||||
// The `node:` prefix can only ever name a builtin, so it never reaches
|
||||
// node_modules and is safe whatever this Node version enumerates.
|
||||
const builtin = isBuiltin(specifier) || specifier.startsWith('node:')
|
||||
if (!builtin && !allowed) {
|
||||
violations.push({ file, specifier, why: 'undeclared bare specifier — should this come from ctx?' })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
module.exports = { scan, stripCommentsAndTemplates, SERVER_ROOT, MODULE_ROOT }
|
||||
|
||||
// Required by a test, or run as the check? Only the second one exits.
|
||||
if (require.main !== module) return
|
||||
|
||||
const violations = scan(SERVER_ROOT)
|
||||
|
||||
if (violations.length) {
|
||||
console.error(`\n${violations.length} import(s) break the module boundary (MODULE_API.md §5.1):\n`)
|
||||
for (const v of violations) {
|
||||
console.error(` ${path.relative(MODULE_ROOT, v.file)}\n "${v.specifier}" — ${v.why}`)
|
||||
}
|
||||
console.error('')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`OK — no import escapes the module root (${SERVER_ROOT}).`)
|
||||
265
server/scripts/swaggerFragment.js
Normal file
265
server/scripts/swaggerFragment.js
Normal file
@@ -0,0 +1,265 @@
|
||||
#!/usr/bin/env node
|
||||
// ── §2.8 — the OpenAPI fragment ───────────────────────────────────────────
|
||||
//
|
||||
// Generates (or checks) `swagger-fragment.json` in the bundle root: the paths,
|
||||
// tags and schemas describing every route this module registers. Core merges the
|
||||
// fragments of *started* modules over its own committed spec at request time and
|
||||
// serves the result at `/api/docs.json` (MODULE_API.md §6.1a).
|
||||
//
|
||||
// ── Why a module has to ship this at all ──────────────────────────────────
|
||||
//
|
||||
// Core's own spec generation is STATIC analysis — swagger-autogen parses core's
|
||||
// `app.js` as text and follows the literal `app.use(...)` chain. Your module
|
||||
// arrives on a volume after core was built, is required by a filesystem loop, and
|
||||
// mounts through `api.registerRoutes()`. There is no literal mount for a parser to
|
||||
// follow, and core does not have your sources anyway. So nothing core can run
|
||||
// will ever describe your routes.
|
||||
//
|
||||
// The failure mode is the dangerous one: swagger-autogen reports success and
|
||||
// emits a spec with the routes simply absent. It happened twice inside core
|
||||
// before anyone noticed, and once to the first module — 417 annotations that
|
||||
// generated nothing at all, for two phases, because nobody had built the
|
||||
// fragment. If you take one thing from this file, take that a green build is not
|
||||
// evidence that anything was described.
|
||||
//
|
||||
// ── Where the prefixes come from ──────────────────────────────────────────
|
||||
//
|
||||
// swagger-autogen is pointed at one router file at a time, so its paths come out
|
||||
// relative to that router (`/status`, not `/api/v1/public/world/status`) —
|
||||
// nothing in the file says where it hangs. §6.1a requires fully-qualified paths,
|
||||
// because core merges the fragment verbatim and never re-derives a prefix.
|
||||
//
|
||||
// So this script **runs your own `register()`** against a recording `api` and
|
||||
// reads the mounts back out. Every prefix is therefore the prefix that router is
|
||||
// actually registered under — the same call an operator's core will make, rather
|
||||
// than a table beside it that drifts the first time a mount moves. Which file a
|
||||
// recorded router object came from is answered by `require.cache`: the module
|
||||
// whose `exports` IS that router.
|
||||
//
|
||||
// The tier base paths are the one thing that cannot be derived here, because they
|
||||
// are core's and not yours. They are §2.4's normative table, quoted below.
|
||||
|
||||
const fs = require('fs')
|
||||
const os = require('os')
|
||||
const path = require('path')
|
||||
|
||||
const swaggerAutogen = require('swagger-autogen')({ openapi: '3.0.0' })
|
||||
|
||||
const { fakeCtx, fakeApi } = require('../test/_fakes')
|
||||
const doc = require('../swagger/doc')
|
||||
|
||||
const MODULE_ROOT = path.resolve(__dirname, '..', '..')
|
||||
const SERVER_ROOT = path.join(MODULE_ROOT, 'server')
|
||||
const FRAGMENT = path.join(MODULE_ROOT, 'swagger-fragment.json')
|
||||
|
||||
// MODULE_API.md §2.4. A router registered under a tier sits inside that tier's
|
||||
// router in core, behind its gate; the base path is core's and fixed.
|
||||
const TIER_BASE = {
|
||||
public: '/api/v1/public',
|
||||
admin: '/api/v1/admin',
|
||||
player: '/api/v1/player',
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `register()` with a recording api and return `[{ file, prefix, what }]`.
|
||||
*
|
||||
* The ctx is the test fakes' — the same one the suite proves the module runs
|
||||
* against — because registration must not touch a database (§2.2), and this
|
||||
* script is exactly the kind of no-database caller that rule exists for.
|
||||
*/
|
||||
function mountedRouters() {
|
||||
const register = require('../index')
|
||||
const api = fakeApi()
|
||||
register(fakeCtx(), api)
|
||||
|
||||
const fileOf = (router) => {
|
||||
for (const mod of Object.values(require.cache)) {
|
||||
if (mod && mod.exports === router) return mod.filename
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const mounts = []
|
||||
for (const [tier, byPrefix] of Object.entries(api.record.routes || {})) {
|
||||
const base = TIER_BASE[tier]
|
||||
if (!base) throw new Error(`swagger: registered under unknown tier "${tier}" — §2.4 has three`)
|
||||
for (const [prefix, router] of Object.entries(byPrefix)) {
|
||||
mounts.push({ router, prefix: base + prefix, what: `${tier}${prefix}` })
|
||||
}
|
||||
}
|
||||
|
||||
return mounts.map(({ router, prefix, what }) => {
|
||||
const file = fileOf(router)
|
||||
if (!file) {
|
||||
// A router built inline in index.js rather than required from its own
|
||||
// file. swagger-autogen needs a file to read, so there is nothing to
|
||||
// generate from — put the router in its own module.
|
||||
throw new Error(`swagger: cannot find the source file of the router for ${what}`)
|
||||
}
|
||||
return { file, prefix, what }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Run swagger-autogen over one router file. Paths come out router-relative.
|
||||
*
|
||||
* **swagger-autogen reports a broken annotation and then succeeds anyway** — it
|
||||
* `console.error`s "Syntax error" or "out of structure", drops that one
|
||||
* annotation, and prints `Success` in green. So its diagnostics are captured here
|
||||
* and made fatal. Nothing else will tell you.
|
||||
*/
|
||||
async function fragmentFor(file) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'module-swagger-'))
|
||||
const out = path.join(dir, 'fragment.json')
|
||||
|
||||
const complaints = []
|
||||
const realError = console.error
|
||||
console.error = (...args) => {
|
||||
const line = args.map(String).join(' ')
|
||||
if (/syntax error|out of structure/i.test(line)) complaints.push(line.trim())
|
||||
else realError(...args)
|
||||
}
|
||||
try {
|
||||
// A DEEP COPY per call, and that is not defensive style. swagger-autogen
|
||||
// writes its result back into the object it was handed, so reusing one `doc`
|
||||
// across several routers re-wraps the previous pass's output every time. The
|
||||
// first module to hit this produced a 484 MB fragment from six routers.
|
||||
await swaggerAutogen(out, [path.relative(SERVER_ROOT, file).split(path.sep).join('/')], {
|
||||
...JSON.parse(JSON.stringify(doc)),
|
||||
info: { title: 'examplegame fragment', version: '0' },
|
||||
})
|
||||
} finally {
|
||||
console.error = realError
|
||||
}
|
||||
if (complaints.length > 0) {
|
||||
throw new Error(
|
||||
`swagger: ${path.relative(MODULE_ROOT, file)} has ${complaints.length} annotation(s) ` +
|
||||
`swagger-autogen could not parse — it drops them and reports success:\n ${complaints.join('\n ')}`,
|
||||
)
|
||||
}
|
||||
|
||||
const fragment = JSON.parse(fs.readFileSync(out, 'utf8'))
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
return fragment
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-root a router-relative fragment under the prefix it is mounted at.
|
||||
*
|
||||
* Express path params (`:id`) become OpenAPI's (`{id}`), and any param belonging
|
||||
* to the PREFIX is moved to the front of each operation's parameter list —
|
||||
* swagger-autogen orders parameters by where they appeared in the path it saw,
|
||||
* which was only the tail.
|
||||
*/
|
||||
function prefixPaths(fragment, prefix) {
|
||||
const oas = prefix.replace(/:([A-Za-z0-9_]+)/g, '{$1}').replace(/\/+$/, '')
|
||||
const outer = [...oas.matchAll(/\{([A-Za-z0-9_]+)\}/g)].map((m) => m[1])
|
||||
const paths = {}
|
||||
for (const [p, item] of Object.entries(fragment.paths || {})) {
|
||||
for (const operation of Object.values(item)) {
|
||||
const params = operation && operation.parameters
|
||||
if (!Array.isArray(params)) continue
|
||||
const rank = (q) => {
|
||||
const i = outer.indexOf(q && q.name)
|
||||
return i === -1 ? outer.length : i
|
||||
}
|
||||
operation.parameters = params
|
||||
.map((q, i) => ({ q, i }))
|
||||
.sort((a, b) => rank(a.q) - rank(b.q) || a.i - b.i)
|
||||
.map(({ q }) => q)
|
||||
}
|
||||
// `router.get('/')` under a prefix concatenates to a trailing slash, a URL no
|
||||
// client calls. Core's generator normalises the same way.
|
||||
paths[`${oas}${p}`.replace(/\/$/, '')] = item
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the whole fragment: every mounted router, re-rooted and merged.
|
||||
*
|
||||
* Only `paths`, `tags` and `components.schemas` — the three sections §6.1a lets a
|
||||
* fragment carry. `info`, `servers` and the security schemes belong to the merged
|
||||
* document, which is to say to core.
|
||||
*/
|
||||
async function build() {
|
||||
const spec = { paths: {}, tags: [], components: { schemas: {} } }
|
||||
let shared = false
|
||||
|
||||
for (const { file, prefix, what } of mountedRouters()) {
|
||||
const generated = await fragmentFor(file)
|
||||
// Tags and schemas are the same on every pass — each was handed the same
|
||||
// `doc` — so take them from whichever ran first. What lands in the fragment
|
||||
// has to be what swagger-autogen PRODUCED and not what it was given: those
|
||||
// two differ (see fragmentFor), and core merges this file verbatim into a
|
||||
// spec whose own schemas went through the same mill.
|
||||
if (!shared) {
|
||||
spec.tags = generated.tags || []
|
||||
spec.components.schemas = (generated.components || {}).schemas || {}
|
||||
shared = true
|
||||
}
|
||||
const paths = prefixPaths(generated, prefix)
|
||||
const count = Object.keys(paths).length
|
||||
if (count === 0) {
|
||||
// An empty result is precisely what the silent drop looks like, so it is a
|
||||
// hard failure rather than a router that happens to declare no routes.
|
||||
throw new Error(`swagger: ${what} (${path.relative(MODULE_ROOT, file)}) generated NO paths`)
|
||||
}
|
||||
for (const [p, item] of Object.entries(paths)) {
|
||||
if (spec.paths[p]) throw new Error(`swagger: two of this module's routers both document ${p}`)
|
||||
spec.paths[p] = item
|
||||
}
|
||||
process.stdout.write(` ${String(count).padStart(3)} path(s) ${prefix} ← ${what}\n`)
|
||||
}
|
||||
|
||||
// Sorted, because swagger-autogen emits router-traversal order: without this,
|
||||
// moving a route between files rewrites most of a committed artifact even when
|
||||
// the API is provably unchanged.
|
||||
spec.paths = Object.fromEntries(Object.entries(spec.paths).sort(([a], [b]) => (a < b ? -1 : 1)))
|
||||
return spec
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const check = process.argv.includes('--check')
|
||||
const spec = await build()
|
||||
const json = `${JSON.stringify(spec, null, 2)}\n`
|
||||
|
||||
if (!check) {
|
||||
fs.writeFileSync(FRAGMENT, json)
|
||||
process.stdout.write(`\nwrote ${path.relative(MODULE_ROOT, FRAGMENT)} — ${Object.keys(spec.paths).length} paths\n`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!fs.existsSync(FRAGMENT)) {
|
||||
process.stderr.write('\nswagger-fragment.json is missing. Run `npm run swagger`.\n')
|
||||
process.exit(1)
|
||||
}
|
||||
// Compared with line endings normalised, and that is not fussiness. A default
|
||||
// Windows clone checks this file out as CRLF while the generator above writes
|
||||
// LF, so a byte comparison failed on a PRISTINE template and told the reader
|
||||
// their routes had changed — the kit's acceptance run lost ten minutes to it
|
||||
// before reaching for `od -c` (docs/modules/kit-acceptance.md, F1). A check may
|
||||
// only fail for the reason it names; this one names a diagnosis, so it has to
|
||||
// be right about it. `.gitattributes` stops the CRLF from arriving in the first
|
||||
// place, and this stops it mattering if it does.
|
||||
const lf = (s) => s.replace(/\r\n/g, '\n')
|
||||
|
||||
if (lf(fs.readFileSync(FRAGMENT, 'utf8')) !== lf(json)) {
|
||||
process.stderr.write(
|
||||
'\nswagger-fragment.json is STALE — the routes or their annotations changed and it was not\n' +
|
||||
'regenerated. Run `npm run swagger` and commit the result. Core merges this file verbatim,\n' +
|
||||
'so a stale one documents a URL surface this module does not serve.\n',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
process.stdout.write(`\nswagger-fragment.json is current — ${Object.keys(spec.paths).length} paths\n`)
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((err) => {
|
||||
process.stderr.write(`${err.stack}\n`)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { mountedRouters, prefixPaths, build, TIER_BASE, FRAGMENT }
|
||||
174
server/sidecarClient.js
Normal file
174
server/sidecarClient.js
Normal file
@@ -0,0 +1,174 @@
|
||||
// ── The near end of a call whose far end is a Rust server ─────────────────
|
||||
//
|
||||
// Every other file in this module reads its own tables. This one is different in
|
||||
// kind: it is the only place that leaves the process.
|
||||
//
|
||||
// **The website process never opens a connection to a game server**
|
||||
// (MODULE_API.md §2.7). It opens one to a `rust-link` sidecar, which owns the
|
||||
// socket to the game, persists what the game says before forwarding it, and
|
||||
// answers reads from that store. `test/noGameConnection.test.js` enforces the
|
||||
// decidable half of that rule and names this file as the one that may reach the
|
||||
// network:
|
||||
//
|
||||
// const MAY_OPEN_SOCKETS = new Set(['sidecarClient.js'])
|
||||
//
|
||||
// ── One client per configured server ──────────────────────────────────────
|
||||
//
|
||||
// R8: the bridge is one game server to one sidecar. So this file takes the
|
||||
// server row as an argument rather than holding a single configured endpoint —
|
||||
// six servers is six base URLs and six tokens, and core never learns there is
|
||||
// more than one.
|
||||
//
|
||||
// ── TIMEOUT_MS is not a tuning knob. It is half of a rule. ────────────────
|
||||
//
|
||||
// An event action declares `budgetMs`, and core's dispatcher enforces it: when
|
||||
// the budget expires it stops waiting and classifies the failure as **retry**,
|
||||
// unconditionally, without asking the action — it cannot ask, the action is still
|
||||
// awaiting a socket. So if core's deadline is shorter than this one, an action
|
||||
// never gets to classify its own failure and `{ ok: false, retry: false }` is
|
||||
// unreachable code. `budgetMs` must EXCEED this.
|
||||
//
|
||||
// It is also bounded from the other side: the sidecar's own RPC reply timeout is
|
||||
// ten seconds, so a value below that would give up while the sidecar is still
|
||||
// legitimately waiting for the game. The ordering is
|
||||
// `sidecar RPC timeout < TIMEOUT_MS < budgetMs`, and every one of the three
|
||||
// is written down somewhere the other two can be checked against.
|
||||
//
|
||||
// ── This file never throws ────────────────────────────────────────────────
|
||||
//
|
||||
// Every call answers `{ ok, status, data }`. A module that let a socket failure
|
||||
// escape into a controller would hand an exception to a page whose whole job is
|
||||
// to render while the game is off. The public site degrades; it does not 500.
|
||||
|
||||
const core = require('./core')
|
||||
|
||||
const log = core.logger('sidecar')
|
||||
|
||||
/** How long this client waits before giving up on a sidecar. See the header. */
|
||||
const TIMEOUT_MS = 12000
|
||||
|
||||
/**
|
||||
* The wire version this module speaks. Declared in three places that must agree:
|
||||
* here, `PROTOCOL_VERSION` in the sidecar, and `overlay.toml` in Rust-Plugins.
|
||||
*
|
||||
* It is sent on every request as `X-RustLink-Version`, which turns a mismatched
|
||||
* deployment into a `409` naming both numbers instead of a parse failure three
|
||||
* layers further in.
|
||||
*/
|
||||
const PROTOCOL_VERSION = 1
|
||||
|
||||
/** What a caller gets back. Shaped once so every call site reads the same. */
|
||||
function reply(ok, status, data = null) {
|
||||
return { ok, status, data }
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalises a configured base URL into something `new URL(path, base)` will not
|
||||
* surprise anybody with.
|
||||
*
|
||||
* A trailing slash on the base and a leading slash on the path is the classic
|
||||
* way to lose a path segment, and an operator pasting a URL out of a terminal
|
||||
* supplies the trailing slash about half the time.
|
||||
*/
|
||||
function joinUrl(baseUrl, path) {
|
||||
return `${String(baseUrl).replace(/\/+$/, '')}${path}`
|
||||
}
|
||||
|
||||
/**
|
||||
* One request to one sidecar.
|
||||
*
|
||||
* @param {object} server a `rust_servers` row, token already decrypted
|
||||
* @param {string} server.baseUrl
|
||||
* @param {string|null} server.token
|
||||
* @param {string} path e.g. `/server`
|
||||
* @param {object} [options]
|
||||
* @param {string} [options.method]
|
||||
* @param {object} [options.body]
|
||||
*/
|
||||
async function request(server, path, { method = 'GET', body = null } = {}) {
|
||||
if (!server || !server.baseUrl) return reply(false, 'not-configured')
|
||||
|
||||
// A sidecar with auth off does not exist — it generates and persists a token on
|
||||
// first start — so a missing token here is a half-finished admin form, not a
|
||||
// sidecar to try unauthenticated. Saying so beats a 401 the operator has to
|
||||
// interpret.
|
||||
if (!server.token) return reply(false, 'no-token')
|
||||
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS)
|
||||
|
||||
try {
|
||||
const res = await fetch(joinUrl(server.baseUrl, path), {
|
||||
method,
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Authorization: `Bearer ${server.token}`,
|
||||
'X-RustLink-Version': String(PROTOCOL_VERSION),
|
||||
...(body ? { 'Content-Type': 'application/json' } : {}),
|
||||
},
|
||||
...(body ? { body: JSON.stringify(body) } : {}),
|
||||
})
|
||||
|
||||
// A protocol mismatch is a deployment fault and deserves its own status, not
|
||||
// to be folded into "the sidecar said no". The operator's fix is an upgrade
|
||||
// of one component, and the message has to be able to say which.
|
||||
if (res.status === 409) {
|
||||
const detail = await safeJson(res)
|
||||
log.warn('protocol mismatch', {
|
||||
server: server.id,
|
||||
module: PROTOCOL_VERSION,
|
||||
sidecar: detail && detail.sidecar_protocol,
|
||||
})
|
||||
return reply(false, 'protocol-mismatch', detail)
|
||||
}
|
||||
|
||||
if (res.status === 401) return reply(false, 'unauthorized')
|
||||
|
||||
// 204 is an ANSWER, not an absence of one: the sidecar is up and reports that
|
||||
// the game has never connected. Collapsing it into a failure would make a
|
||||
// freshly installed server indistinguishable from an unreachable one.
|
||||
if (res.status === 204) return reply(true, 'empty', null)
|
||||
|
||||
if (!res.ok) return reply(false, `http-${res.status}`)
|
||||
|
||||
return reply(true, 'ok', await safeJson(res))
|
||||
} catch (err) {
|
||||
// `AbortError` is this client's own deadline firing, and it is worth telling
|
||||
// apart from a refused connection: one means the sidecar is slow or the game
|
||||
// is not answering, the other means nothing is listening.
|
||||
const status = err && err.name === 'AbortError' ? 'timeout' : 'transport-error'
|
||||
log.warn('sidecar request failed', { server: server.id, path, status, error: err.message })
|
||||
return reply(false, status)
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
async function safeJson(res) {
|
||||
try {
|
||||
return await res.json()
|
||||
} catch {
|
||||
// A sidecar that answered 200 with something that is not JSON is a sidecar
|
||||
// this module cannot use, but it is not a reason to throw at a page.
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Liveness, the protocol version, and whether the plugin is connected. Unauthenticated at the far end, but sent authenticated anyway so one code path covers every call. */
|
||||
const health = (server) => request(server, '/health')
|
||||
|
||||
/** The last `server.hello` the sidecar stored. Answers while the game is off. */
|
||||
const serverBoard = (server) => request(server, '/server')
|
||||
|
||||
/** A live round trip through the sidecar to the game. Fails when the game is down, by design. */
|
||||
const liveStatus = (server) => request(server, '/status')
|
||||
|
||||
module.exports = {
|
||||
TIMEOUT_MS,
|
||||
PROTOCOL_VERSION,
|
||||
request,
|
||||
health,
|
||||
serverBoard,
|
||||
liveStatus,
|
||||
joinUrl,
|
||||
}
|
||||
130
server/swagger/doc.js
Normal file
130
server/swagger/doc.js
Normal file
@@ -0,0 +1,130 @@
|
||||
// ── The OpenAPI fragment: the shared half ─────────────────────────────────
|
||||
//
|
||||
// The tags and component schemas the `#swagger.*` annotations refer to.
|
||||
// `scripts/swaggerFragment.js` feeds this to swagger-autogen; the per-endpoint
|
||||
// detail lives beside each route, exactly as it does in core.
|
||||
//
|
||||
// **Two rules about names, and both belong to the MERGED document rather than to
|
||||
// this file** (MODULE_API.md §6.1a). Core merges every started module's fragment
|
||||
// over its own committed spec and serves the result at `/api/docs.json`, and core
|
||||
// wins any key collision:
|
||||
//
|
||||
// • **Namespace what you DEFINE.** `RustServerList`, not `ServerList`. A second
|
||||
// game's module describing the same idea under the same bare name would
|
||||
// silently clobber this one or be clobbered by it.
|
||||
// • **Reference what CORE defines by core's name.** `#/components/schemas/Error`
|
||||
// and `ValidationError` are core's; point at them and do not redefine them.
|
||||
//
|
||||
// **swagger-autogen renders `components.schemas` from an EXAMPLE object, not from
|
||||
// raw OpenAPI.** `{ type: 'object' }` comes back as a meta-description of itself.
|
||||
// That is uniform across core's committed spec and is the house shape.
|
||||
|
||||
module.exports = {
|
||||
tags: [
|
||||
{
|
||||
name: 'Public · Rust',
|
||||
description: 'The Rust servers this site follows, as each one last reported itself',
|
||||
},
|
||||
{
|
||||
name: 'Player · Rust',
|
||||
description: 'The Rust surface for a signed-in player',
|
||||
},
|
||||
{
|
||||
name: 'Admin · Rust',
|
||||
description: 'Configuring the Rust servers and their sidecars',
|
||||
},
|
||||
],
|
||||
components: {
|
||||
schemas: {
|
||||
RustServerList: {
|
||||
type: 'object',
|
||||
description: 'Every Rust server this site follows (GET /public/rust/servers).',
|
||||
properties: {
|
||||
servers: {
|
||||
type: 'array',
|
||||
items: { $ref: '#/components/schemas/RustServer' },
|
||||
},
|
||||
},
|
||||
},
|
||||
RustServer: {
|
||||
type: 'object',
|
||||
description: 'One Rust server, as it last reported itself.',
|
||||
properties: {
|
||||
id: { type: 'string', example: 'main' },
|
||||
name: { type: 'string', example: 'Main · Vanilla' },
|
||||
online: { type: 'boolean', example: true },
|
||||
players: { type: 'integer', example: 42 },
|
||||
maxPlayers: { type: 'integer', example: 100 },
|
||||
hostname: { type: 'string', nullable: true, example: 'Runic Gateway · Main' },
|
||||
level: { type: 'string', nullable: true, example: 'Procedural Map' },
|
||||
worldSize: { type: 'integer', nullable: true, example: 4000 },
|
||||
seed: { type: 'integer', nullable: true, example: 1234 },
|
||||
updatedAt: { type: 'string', format: 'date-time', nullable: true },
|
||||
stale: {
|
||||
type: 'boolean',
|
||||
description: 'Has nothing reported in longer than the freshness window? A stale row is reported offline.',
|
||||
example: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
RustAdminServerList: {
|
||||
type: 'object',
|
||||
description: 'The configured servers, with their sidecar settings (GET /admin/rust/servers).',
|
||||
properties: {
|
||||
servers: {
|
||||
type: 'array',
|
||||
items: { $ref: '#/components/schemas/RustAdminServer' },
|
||||
},
|
||||
},
|
||||
},
|
||||
RustAdminServer: {
|
||||
type: 'object',
|
||||
description: 'One configured server. The sidecar token is never included — `hasToken` reports only whether one is stored.',
|
||||
properties: {
|
||||
id: { type: 'string', example: 'main' },
|
||||
name: { type: 'string', example: 'Main · Vanilla' },
|
||||
sidecarBaseUrl: { type: 'string', example: 'http://10.0.0.5:8090' },
|
||||
hasToken: { type: 'boolean', example: true },
|
||||
protocol: { type: 'integer', example: 1 },
|
||||
enabled: { type: 'boolean', example: true },
|
||||
sortOrder: { type: 'integer', example: 0 },
|
||||
reachable: {
|
||||
type: 'boolean',
|
||||
description: 'Did the sidecar answer on the last poll? Separate from `online`, which is about the game rather than the bridge.',
|
||||
example: true,
|
||||
},
|
||||
bootId: { type: 'string', nullable: true, example: 'boot-20260915T194502Z' },
|
||||
sidecarProtocol: { type: 'integer', nullable: true, example: 1 },
|
||||
online: { type: 'boolean', example: true },
|
||||
players: { type: 'integer', example: 42 },
|
||||
stale: { type: 'boolean', example: false },
|
||||
},
|
||||
},
|
||||
RustSidecarProbe: {
|
||||
type: 'object',
|
||||
description: 'What a sidecar said when probed (POST /admin/rust/servers/{id}/test).',
|
||||
properties: {
|
||||
ok: { type: 'boolean', example: true },
|
||||
status: {
|
||||
type: 'string',
|
||||
description: 'What happened, in one word — this is what tells a wrong URL from a wrong token from a mismatched protocol. One of `ok`, `no-token`, `unauthorized`, `protocol-mismatch`, `timeout`, `transport-error`, or `http-<code>`.',
|
||||
example: 'ok',
|
||||
},
|
||||
sidecar: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
description: 'The sidecar’s own health document, or the mismatch detail on a protocol disagreement.',
|
||||
properties: {
|
||||
status: { type: 'string', example: 'ok' },
|
||||
protocol: { type: 'integer', example: 1 },
|
||||
plugin_connected: { type: 'boolean', example: true },
|
||||
database: { type: 'string', example: 'ok' },
|
||||
uptime: { type: 'string', example: '3h 2m' },
|
||||
last_event: { type: 'string', format: 'date-time', nullable: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
154
server/test/_fakes.js
Normal file
154
server/test/_fakes.js
Normal file
@@ -0,0 +1,154 @@
|
||||
// ── Test doubles for what core hands the module ───────────────────────────
|
||||
//
|
||||
// Your server half is testable WITHOUT core, and that is not a convenience — it
|
||||
// is the contract holding. Everything a module may touch arrives on `ctx`
|
||||
// (MODULE_API.md §2.3), so a `ctx` this file can build is a complete statement of
|
||||
// what your module depends on. **If a test ever needs something that is not here,
|
||||
// either your module reached past the boundary or §2.3 needs a new member.** Both
|
||||
// are worth stopping for.
|
||||
//
|
||||
// The fake mirrors §2.3 member for member — including the freezing, so a module
|
||||
// that assigns to `ctx.something` fails here the way it would in core.
|
||||
//
|
||||
// This file lives under `test/`, which `checkImports.js` treats as not-shipped —
|
||||
// which is why it may `require('express')` when the module's own routers may not.
|
||||
// It builds a REAL express Router on purpose: a fake Router would only ever test
|
||||
// the fake.
|
||||
|
||||
const express = require('express')
|
||||
const expressValidator = require('express-validator')
|
||||
|
||||
/** Records every call, so a test can assert what the module asked for. */
|
||||
function spy(returns) {
|
||||
const fn = (...args) => {
|
||||
fn.calls.push(args)
|
||||
return typeof returns === 'function' ? returns(...args) : returns
|
||||
}
|
||||
fn.calls = []
|
||||
return fn
|
||||
}
|
||||
|
||||
function fakeLog() {
|
||||
return { error: spy(), warn: spy(), info: spy(), debug: spy() }
|
||||
}
|
||||
|
||||
function fakeCtx(overrides = {}) {
|
||||
// `freeze: false` is a test seam for a suite that wants to adjust the ctx it
|
||||
// installed. Core always freezes; the unfrozen variant is never a claim about
|
||||
// what a module is handed in production.
|
||||
const { freeze = true, ...rest } = overrides
|
||||
const logs = []
|
||||
const ctx = {
|
||||
moduleId: 'rust',
|
||||
paths: { moduleRoot: require('path').resolve(__dirname, '..', '..') },
|
||||
express,
|
||||
// The REAL express-validator, for the same reason express is real: the admin
|
||||
// router builds its validation chains at file scope, so `{}` here is not
|
||||
// something that file can even be required with.
|
||||
validator: expressValidator,
|
||||
db: { query: spy(Promise.resolve([])), pool: {} },
|
||||
log: (namespace) => {
|
||||
const log = fakeLog()
|
||||
logs.push({ namespace, log })
|
||||
return log
|
||||
},
|
||||
auth: { getUserFromRequest: spy(null) },
|
||||
// The engagement seam (§2.3). One method, recording, because that is the
|
||||
// whole of what a module may do with it: fire a declared event and stop.
|
||||
// Core's own emit is fire-and-forget and returns nothing, so this does too —
|
||||
// a fake that returned a receipt would invite a module to wait on one.
|
||||
// `reconcile` joined it at 1.10.0 — the ONE thing the event contract adds to
|
||||
// `ctx`, because an action is called BY core and is handed what it needs in
|
||||
// the envelope. Only the module knows when the game restarted, so only the
|
||||
// module can ask for the sweep.
|
||||
events: { emit: spy(undefined), reconcile: spy(undefined) },
|
||||
// A REVERSIBLE fake, not a recording one. Core's box is AES-256-GCM keyed by
|
||||
// the deployment's SECRET_ENC_KEY; what a test needs from it is that
|
||||
// `decrypt(encrypt(x)) === x`, because the bug this module could have is a
|
||||
// token stored under one shape and read under another. A spy returning a
|
||||
// constant would pass while proving nothing, and the tag makes an accidental
|
||||
// plaintext leak visible in an assertion.
|
||||
secretBox: {
|
||||
encrypt: (s) => `enc:${s}`,
|
||||
decrypt: (s) => {
|
||||
if (typeof s !== 'string' || !s.startsWith('enc:')) throw new Error('not encrypted by this box')
|
||||
return s.slice(4)
|
||||
},
|
||||
},
|
||||
activity: { log: spy(Promise.resolve()) },
|
||||
middleware: {
|
||||
requireAuth: (req, res, next) => next(),
|
||||
requireRole: () => (req, res, next) => next(),
|
||||
siteMode: (req, res, next) => next(),
|
||||
validate: (req, res, next) => next(),
|
||||
noindex: (req, res, next) => next(),
|
||||
// The factory returns a pass-through rather than a real limiter: a test
|
||||
// that tripped a rate limit would be a test whose result depended on how
|
||||
// many times the suite had run.
|
||||
rateLimit: (options) => Object.assign((req, res, next) => next(), { options }),
|
||||
accountChangeLimiter: (req, res, next) => next(),
|
||||
},
|
||||
site: { baseUrl: 'http://localhost:5173' },
|
||||
...rest,
|
||||
}
|
||||
// Non-enumerable, and that is not tidiness. Core freezes every object value on
|
||||
// `ctx` one level deep, so an enumerable recorder hung off it would be frozen
|
||||
// by the loop below and every `log.info` would throw on push. Keeping it out of
|
||||
// the enumeration also makes the fake more faithful: a module iterating `ctx`
|
||||
// sees §2.3's members and nothing a test put there.
|
||||
Object.defineProperty(ctx, 'logs', { value: logs, enumerable: false })
|
||||
if (!freeze) return ctx
|
||||
for (const value of Object.values(ctx)) {
|
||||
if (value && typeof value === 'object') Object.freeze(value)
|
||||
}
|
||||
return Object.freeze(ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
* The registration api, recording rather than mounting.
|
||||
*
|
||||
* Copies core's `once()` rule (§2.4: "calling twice is an error"), so a module
|
||||
* that registers the same thing twice fails in its own suite rather than first on
|
||||
* an operator's install.
|
||||
*/
|
||||
function fakeApi() {
|
||||
const record = {
|
||||
routes: null, extensions: [], streams: null, legs: [], hooks: {}, teamProvider: null,
|
||||
triggers: null, audiences: null, engagementSeeds: null,
|
||||
eventBudgets: null, eventOptionSources: null, eventLeases: null, eventActions: null,
|
||||
}
|
||||
const called = new Set()
|
||||
const once = (name) => {
|
||||
if (called.has(name)) throw new Error(`${name}() called twice`)
|
||||
called.add(name)
|
||||
}
|
||||
const api = {
|
||||
registerRoutes(mounts) { once('registerRoutes'); record.routes = mounts },
|
||||
registerExtension(slot, router) { record.extensions.push({ slot, router }) },
|
||||
registerNotificationStreams(streams) { once('registerNotificationStreams'); record.streams = streams },
|
||||
registerAnnounceLeg(leg) { record.legs.push(leg) },
|
||||
registerPostHook(hook) { once('registerPostHook'); record.hooks.post = hook },
|
||||
// `once` here is not the general rule restated — it is a DIFFERENT rule that
|
||||
// happens to look the same. The others may not be called twice by ONE module;
|
||||
// this one holds a single value across the whole deployment, so a second
|
||||
// module registering a provider collides with the first. A fake cannot see
|
||||
// the second module, and asserting the half it can see is still worth doing.
|
||||
registerTeamProvider(provider) { once('registerTeamProvider'); record.teamProvider = provider },
|
||||
registerEventTriggers(triggers) { once('registerEventTriggers'); record.triggers = triggers },
|
||||
registerAudiences(audiences) { once('registerAudiences'); record.audiences = audiences },
|
||||
registerEngagementSeeds(seeds) { once('registerEngagementSeeds'); record.engagementSeeds = seeds },
|
||||
// The event contract (1.10.0). `once` on all four: a batch is a module's
|
||||
// COMPLETE statement about what it declares, so a second call is a module
|
||||
// changing its mind halfway through `register()` rather than adding to it.
|
||||
registerEventBudgets(budgets) { once('registerEventBudgets'); record.eventBudgets = budgets },
|
||||
registerEventOptionSources(sources) { once('registerEventOptionSources'); record.eventOptionSources = sources },
|
||||
registerEventLeases(leases) { once('registerEventLeases'); record.eventLeases = leases },
|
||||
registerEventActions(actions) { once('registerEventActions'); record.eventActions = actions },
|
||||
onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },
|
||||
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn },
|
||||
}
|
||||
api.record = record
|
||||
return api
|
||||
}
|
||||
|
||||
module.exports = { fakeCtx, fakeApi, spy }
|
||||
149
server/test/checkImports.test.js
Normal file
149
server/test/checkImports.test.js
Normal file
@@ -0,0 +1,149 @@
|
||||
// The boundary check, checked.
|
||||
//
|
||||
// `scripts/checkImports.js` is the acceptance test for the whole module contract
|
||||
// (MODULE_API.md §5.1), and a check that has never been shown to fail is a check
|
||||
// nobody knows the state of. These point it at fixtures that break each rule and
|
||||
// assert it says so — and at prose that merely *describes* breaking them, which
|
||||
// is what it got wrong the first time it was run.
|
||||
//
|
||||
// **Every fixture is a template literal, and that is load-bearing.** The scanner
|
||||
// reads the files in this directory too, so an ordinary quoted string holding
|
||||
// `require('../../x')` would make this file fail the very check it is testing.
|
||||
// Templates are blanked by the stripper for exactly this class of text: source
|
||||
// being composed as data is not source being imported.
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
|
||||
const { scan, stripCommentsAndTemplates, SERVER_ROOT, MODULE_ROOT } = require('../scripts/checkImports')
|
||||
|
||||
/** Write `files` into a throwaway module tree and scan it. */
|
||||
function scanFixture(files, { dev = new Set() } = {}) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'module-tpl-'))
|
||||
const src = path.join(root, 'server')
|
||||
for (const [name, source] of Object.entries(files)) {
|
||||
const file = path.join(src, name)
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true })
|
||||
fs.writeFileSync(file, source)
|
||||
}
|
||||
try {
|
||||
return scan(src, root, { shipped: (f) => !f.startsWith(path.join(src, 'test') + path.sep), dev })
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
test('the real server half is clean', () => {
|
||||
assert.deepStrictEqual(scan(SERVER_ROOT, MODULE_ROOT), [])
|
||||
})
|
||||
|
||||
test('catches a relative path that escapes the module root', () => {
|
||||
const found = scanFixture({ 'a.js': `require('../../server/src/utils/db')` })
|
||||
assert.strictEqual(found.length, 1)
|
||||
assert.strictEqual(found[0].why, 'escapes the module root')
|
||||
})
|
||||
|
||||
test('allows a relative path that stays inside it, however deep', () => {
|
||||
assert.deepStrictEqual(
|
||||
scanFixture({ 'deep/nested/a.js': `require('../../../module.json')` }),
|
||||
[],
|
||||
)
|
||||
})
|
||||
|
||||
test('catches an absolute path', () => {
|
||||
const found = scanFixture({ 'a.js': `require('/etc/passwd')` })
|
||||
assert.strictEqual(found[0].why, 'absolute path')
|
||||
})
|
||||
|
||||
test('catches a bare specifier in shipped code, even a devDependency', () => {
|
||||
// The rule that makes the boundary real: express arrives on ctx. A shipped
|
||||
// file requiring it would fail on a real install, because a module lives
|
||||
// outside core's server/ and never reaches core's node_modules.
|
||||
const found = scanFixture({ 'a.js': `const express = require('express')` }, { dev: new Set(['express']) })
|
||||
assert.strictEqual(found.length, 1)
|
||||
assert.match(found[0].why, /should this come from ctx/)
|
||||
})
|
||||
|
||||
test('allows a devDependency in test code, which never runs inside core', () => {
|
||||
assert.deepStrictEqual(
|
||||
scanFixture({ 'test/a.js': `const express = require('express')` }, { dev: new Set(['express']) }),
|
||||
[],
|
||||
)
|
||||
})
|
||||
|
||||
test('allows node builtins anywhere, with or without the node: prefix', () => {
|
||||
assert.deepStrictEqual(
|
||||
scanFixture({ 'a.js': `require('path'); require('node:fs'); import crypto from 'node:crypto'` }),
|
||||
[],
|
||||
)
|
||||
})
|
||||
|
||||
test('allows node:test, which older Node versions omit from builtinModules', () => {
|
||||
// The first CI run failed on exactly this and on nothing else: `builtinModules`
|
||||
// omits `test` on Node 20 and includes it on Node 24, so every test file in
|
||||
// this suite was reported as breaking the module boundary. The check asks
|
||||
// Node (`isBuiltin`) rather than rebuilding the list, and treats the `node:`
|
||||
// prefix as sufficient on its own — a prefixed specifier can never resolve to
|
||||
// a package, whatever the running version enumerates.
|
||||
assert.deepStrictEqual(
|
||||
scanFixture({ 'a.js': `require('node:test'); require('node:test/reporters')` }),
|
||||
[],
|
||||
)
|
||||
})
|
||||
|
||||
test('catches ESM and dynamic forms, not only require()', () => {
|
||||
const found = scanFixture({
|
||||
'a.js': [`import db from '../../core/db.js'`, `const x = await import('../../core/other.js')`].join('\n'),
|
||||
})
|
||||
assert.strictEqual(found.length, 2)
|
||||
})
|
||||
|
||||
test('ignores a violation that is only DESCRIBED in a comment', () => {
|
||||
// The first run of this check failed on its own documentation, and on
|
||||
// index.js's comment explaining why the module must never require('express').
|
||||
// Prose about the rule must not trip the rule.
|
||||
assert.deepStrictEqual(
|
||||
scanFixture({
|
||||
'a.js': [
|
||||
`// Never write require("../../server/src/utils/db") - it escapes the module root.`,
|
||||
`/* Nor import express from "express": core hands it over on ctx. */`,
|
||||
`const path = require('path')`,
|
||||
].join('\n'),
|
||||
}),
|
||||
[],
|
||||
)
|
||||
})
|
||||
|
||||
test('ignores a specifier-shaped string inside a template literal', () => {
|
||||
assert.deepStrictEqual(
|
||||
scanFixture({ 'a.js': ['const sql = ', '`SELECT 1 -- require("../../x")`'].join('') }),
|
||||
[],
|
||||
)
|
||||
})
|
||||
|
||||
test('a comment opener inside a string does not swallow the rest of the file', () => {
|
||||
// The reason this is a character walk and not a regexp: a URL in a string
|
||||
// contains `//`, and treating that as a comment would blank everything after
|
||||
// it — turning the check into one that silently passes.
|
||||
const found = scanFixture({
|
||||
'a.js': [`const url = 'https://example.com/x'`, `require('../../escaped')`].join('\n'),
|
||||
})
|
||||
assert.strictEqual(found.length, 1, 'the specifier after a URL string was missed')
|
||||
})
|
||||
|
||||
test('a quote inside a comment does not swallow the rest of the file', () => {
|
||||
const found = scanFixture({
|
||||
'a.js': [`// don't do this`, `require('../../escaped')`].join('\n'),
|
||||
})
|
||||
assert.strictEqual(found.length, 1)
|
||||
})
|
||||
|
||||
test('stripping preserves line numbers', () => {
|
||||
// Blanked rather than removed, so anything that later reports a line still
|
||||
// reports the right one.
|
||||
const src = ['/* a', 'b', 'c */', `require("x")`, ''].join('\n')
|
||||
assert.strictEqual(stripCommentsAndTemplates(src).split('\n').length, src.split('\n').length)
|
||||
})
|
||||
150
server/test/entry.test.js
Normal file
150
server/test/entry.test.js
Normal file
@@ -0,0 +1,150 @@
|
||||
// ── The registration handshake ────────────────────────────────────────────
|
||||
//
|
||||
// The one suite every module should have, whatever else it does. Core validates
|
||||
// all of this at boot and refuses to mount a module that fails — so testing it
|
||||
// here is the difference between finding out in half a second and finding out on
|
||||
// an operator's install.
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
|
||||
const { fakeCtx, fakeApi } = require('./_fakes')
|
||||
const manifest = require('../../module.json')
|
||||
|
||||
/** A fresh registration. `core.js` holds a module-level `ctx`, so reset it. */
|
||||
function register(ctx = fakeCtx()) {
|
||||
require('../core')._reset()
|
||||
const api = fakeApi()
|
||||
require('../index')(ctx, api)
|
||||
return { api, ctx }
|
||||
}
|
||||
|
||||
test('registers exactly the mounts module.json declares', () => {
|
||||
const { api } = register()
|
||||
|
||||
// Core compares these two and rejects a mismatch in EITHER direction: a prefix
|
||||
// declared and never registered is as fatal as a route registered and never
|
||||
// declared. Asserting against the manifest rather than against a literal is
|
||||
// what keeps the test true after a prefix is added.
|
||||
assert.deepStrictEqual(
|
||||
Object.keys(api.record.routes).sort(),
|
||||
Object.keys(manifest.mounts).sort(),
|
||||
)
|
||||
for (const [tier, prefixes] of Object.entries(manifest.mounts)) {
|
||||
assert.deepStrictEqual(Object.keys(api.record.routes[tier]).sort(), [...prefixes].sort())
|
||||
}
|
||||
})
|
||||
|
||||
test('all three tiers are mounted (R14)', () => {
|
||||
const { api } = register()
|
||||
|
||||
// Not the assertion above restated. That one says the manifest and the code
|
||||
// agree; this one says WHICH answer they agree on, so that deleting a tier from
|
||||
// both halves at once still fails. R14 puts this module on all three from the
|
||||
// start precisely so that a later phase adding a player surface does not have
|
||||
// to move an address clients are already calling.
|
||||
assert.deepStrictEqual(Object.keys(api.record.routes).sort(), ['admin', 'player', 'public'])
|
||||
for (const tier of ['admin', 'player', 'public']) {
|
||||
assert.deepStrictEqual(Object.keys(api.record.routes[tier]), ['/rust'])
|
||||
}
|
||||
})
|
||||
|
||||
test('every registered mount is a real express router', () => {
|
||||
const { api } = register()
|
||||
for (const byPrefix of Object.values(api.record.routes)) {
|
||||
for (const [prefix, router] of Object.entries(byPrefix)) {
|
||||
assert.strictEqual(typeof router, 'function', `${prefix} is not a router`)
|
||||
assert.ok(router.stack, `${prefix} has no middleware stack`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('prefixes are one segment, lowercase, no parameters', () => {
|
||||
// §2.4's rule, restated where a typo is cheap to find. Core enforces it, and a
|
||||
// module that fails it does not mount at all.
|
||||
for (const prefixes of Object.values(manifest.mounts)) {
|
||||
for (const prefix of prefixes) {
|
||||
assert.match(prefix, /^\/[a-z0-9][a-z0-9-]*$/, `illegal mount prefix ${prefix}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('registration touches no database and awaits nothing', () => {
|
||||
const ctx = fakeCtx()
|
||||
register(ctx)
|
||||
|
||||
// §2.2's first rule. Core requires `app.js` with the pool pointed at a dead
|
||||
// port in two build tools, so a query here would hang both — and the symptom is
|
||||
// a build that never finishes rather than an error naming this module.
|
||||
assert.deepStrictEqual(ctx.db.query.calls, [])
|
||||
})
|
||||
|
||||
test('registers both lifecycle hooks', () => {
|
||||
const { api } = register()
|
||||
assert.strictEqual(typeof api.record.hooks.onBoot, 'function')
|
||||
assert.strictEqual(typeof api.record.hooks.onShutdown, 'function')
|
||||
})
|
||||
|
||||
test('the manifest declares what the loader requires', () => {
|
||||
assert.match(manifest.id, /^[a-z][a-z0-9-]{1,31}$/)
|
||||
assert.match(manifest.version, /^\d+\.\d+\.\d+/)
|
||||
assert.ok(manifest.coreApi, 'coreApi is required — it is the version check')
|
||||
// Declaring a schema without a purge is refused: a module that can create
|
||||
// tables and cannot drop them leaves an operator with orphaned data.
|
||||
if (manifest.schema) assert.ok(manifest.purge, 'a schema fragment requires a purge file')
|
||||
// The chunk must be in a SUBDIRECTORY — the directory it sits in is what core
|
||||
// serves, so an entry in the module root would publish the whole module.
|
||||
if (manifest.client) assert.ok(manifest.client.entry.includes('/'), 'client.entry must be in a subdirectory')
|
||||
})
|
||||
|
||||
test('the manifest declares no extension slot it does not fill', () => {
|
||||
const { api } = register()
|
||||
|
||||
// §11.3 of the plan reads `extensions` as "declared, and held against reality
|
||||
// by the loader". Only the first half is true: the loader checks that a named
|
||||
// slot EXISTS (`registries.hasSlot`) and never checks that the module went on
|
||||
// to fill it — `checkDeclared` covers `mounts` alone. So a declaration with
|
||||
// nothing behind it loads cleanly and means nothing, which is exactly why this
|
||||
// module does not write one until it has an extension to register.
|
||||
//
|
||||
// The other half of that correction: `admin.users.detail` is the ONLY server
|
||||
// slot core declares. `site.footer.status` is a CLIENT slot and is registered
|
||||
// from the chunk — naming it here would fail the load with
|
||||
// `unknown extension slot "site.footer.status"`.
|
||||
const declared = manifest.extensions || []
|
||||
const filled = api.record.extensions.map((e) => e.slot)
|
||||
assert.deepStrictEqual([...declared].sort(), [...filled].sort())
|
||||
})
|
||||
|
||||
test('nothing is registered that has nothing behind it yet', () => {
|
||||
const { api } = register()
|
||||
|
||||
// The phase-1 statement, written down so that removing it is deliberate. A
|
||||
// declared trigger nothing emits and a declared slot nothing fills are both
|
||||
// surfaces an operator can configure and then wait on — worse than an absent
|
||||
// one, because the absence is visible. Each of these arrives with the phase
|
||||
// that has something real to put in it, and this assertion is what that phase
|
||||
// deletes.
|
||||
assert.strictEqual(api.record.teamProvider, null)
|
||||
assert.strictEqual(api.record.triggers, null)
|
||||
assert.strictEqual(api.record.audiences, null)
|
||||
assert.strictEqual(api.record.engagementSeeds, null)
|
||||
assert.strictEqual(api.record.streams, null)
|
||||
assert.strictEqual(api.record.eventBudgets, null)
|
||||
assert.strictEqual(api.record.eventOptionSources, null)
|
||||
assert.strictEqual(api.record.eventLeases, null)
|
||||
assert.strictEqual(api.record.eventActions, null)
|
||||
})
|
||||
|
||||
test('the module’s protocol version agrees with the manifest it ships beside', () => {
|
||||
const sidecar = require('../sidecarClient')
|
||||
|
||||
// The wire version is declared in three repos — here, `PROTOCOL_VERSION` in
|
||||
// the sidecar, and `overlay.toml` in the plugin overlay — and nothing in one
|
||||
// repo can check the other two. What CAN be checked is that this repo says one
|
||||
// thing: the number the client sends is the number an operator sees on a
|
||||
// freshly created server row, so a bump that edits one and not the other
|
||||
// configures every new server against a version the client does not speak.
|
||||
assert.strictEqual(typeof sidecar.PROTOCOL_VERSION, 'number')
|
||||
assert.ok(sidecar.PROTOCOL_VERSION >= 1)
|
||||
})
|
||||
151
server/test/noGameConnection.test.js
Normal file
151
server/test/noGameConnection.test.js
Normal file
@@ -0,0 +1,151 @@
|
||||
// ── §2.7's last rule, given the CI it does not have ───────────────────────
|
||||
//
|
||||
// `book/02-website-module.md` is explicit that "the website process never opens a
|
||||
// connection to a game server" is the **one boundary rule with no CI behind it**:
|
||||
// an outbound socket is not statically detectable the way an internal `require`
|
||||
// is, so in general the rule is held up by review and by understanding it.
|
||||
//
|
||||
// True of the general case, and not a reason to check nothing. A module can state
|
||||
// a narrower, completely decidable property about **itself**, and this one says:
|
||||
// the shipped server half references no networking primitive at all. Everything
|
||||
// it knows arrives from its own tables, which its sidecar writes.
|
||||
//
|
||||
// Adopted from the kit's acceptance run (`docs/modules/kit-acceptance.md`), where
|
||||
// a reader building a Rust module wrote it unprompted after reading that the rule
|
||||
// had no CI — and observed that for Rust in particular, which ships RCON over
|
||||
// WebSocket, `new WebSocket(rconUrl)` in `boot.js` is about ten lines away.
|
||||
//
|
||||
// ── NARROWED, NOT DELETED ─────────────────────────────────────────────────
|
||||
//
|
||||
// This module has a real sidecar client, so the check is narrowed to allow that
|
||||
// one file and keeps the rest of the tree under the ban. Talking to *the sidecar*
|
||||
// over HTTP is the expected shape and is not what §2.7 forbids — the rule is
|
||||
// about the **game server**.
|
||||
//
|
||||
// const MAY_OPEN_SOCKETS = new Set(['sidecarClient.js'])
|
||||
//
|
||||
// What that buys is a test naming the *one* file allowed to reach the network —
|
||||
// exactly the file a reviewer should read closely, and exactly the place a
|
||||
// game-server URL would appear if the rule were ever broken. The temptation on a
|
||||
// red run here is to add a second name; the answer is almost always to move the
|
||||
// call into `sidecarClient.js` instead.
|
||||
//
|
||||
// It is worth saying what this does NOT prove. `sidecarClient.js` is exempt, so
|
||||
// nothing here stops it being pointed at a game server's own port — it would
|
||||
// take a URL an operator typed. The decidable half is that no OTHER file can
|
||||
// reach the network at all, which is what keeps the exempt file small enough to
|
||||
// read.
|
||||
//
|
||||
// Scope: SHIPPED code only. `test/` and `scripts/` never run inside core's process.
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const SERVER_ROOT = path.resolve(__dirname, '..')
|
||||
const NOT_SHIPPED = new Set(['test', 'scripts', 'node_modules', 'swagger'])
|
||||
|
||||
/**
|
||||
* The one shipped file allowed to reach the network. See the header.
|
||||
*
|
||||
* Kept as a set of BASENAMES rather than paths, so that moving the file does not
|
||||
* silently re-ban it — a rename is meant to be a conversation.
|
||||
*/
|
||||
const MAY_OPEN_SOCKETS = new Set(['sidecarClient.js'])
|
||||
|
||||
/** Every shipped `.js` file under `server/`. */
|
||||
function shippedFiles(dir = SERVER_ROOT, out = []) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) {
|
||||
if (dir === SERVER_ROOT && NOT_SHIPPED.has(entry.name)) continue
|
||||
if (entry.name === 'node_modules') continue
|
||||
shippedFiles(path.join(dir, entry.name), out)
|
||||
} else if (entry.isFile() && entry.name.endsWith('.js')) {
|
||||
out.push(path.join(dir, entry.name))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Blank comments, so prose ABOUT the rule does not trip the rule.
|
||||
*
|
||||
* This file is itself the proof that it is needed: the paragraphs above say
|
||||
* "WebSocket" several times. `scripts/checkImports.js` documents hitting exactly
|
||||
* this on its own documentation, and it is the third time in this project's
|
||||
* history that a boundary check has failed on the text explaining it.
|
||||
*
|
||||
* Blanked rather than deleted, so line numbers in a failure still point at the
|
||||
* right line.
|
||||
*/
|
||||
function stripComments(src) {
|
||||
return src
|
||||
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '))
|
||||
.replace(/^[ \t]*\/\/.*$/gm, '')
|
||||
}
|
||||
|
||||
// Each is a way a Node process opens a socket. Matched as identifiers, so a
|
||||
// column named `websocket_url` inside a SQL string would not fire.
|
||||
const NETWORKING = [
|
||||
/\brequire\(\s*['"](?:node:)?(?:net|tls|dgram|http|https|http2)['"]\s*\)/,
|
||||
/\bfrom\s+['"](?:node:)?(?:net|tls|dgram|http|https|http2)['"]/,
|
||||
/\brequire\(\s*['"](?:ws|socket\.io-client|undici|axios|node-fetch|got)['"]\s*\)/,
|
||||
/\bnew\s+WebSocket\b/,
|
||||
/\bfetch\s*\(/,
|
||||
/\bXMLHttpRequest\b/,
|
||||
/\bEventSource\b/,
|
||||
]
|
||||
|
||||
test('no shipped file references a networking primitive (§2.7)', () => {
|
||||
const offenders = []
|
||||
for (const file of shippedFiles()) {
|
||||
if (MAY_OPEN_SOCKETS.has(path.basename(file))) continue
|
||||
const code = stripComments(fs.readFileSync(file, 'utf8'))
|
||||
for (const pattern of NETWORKING) {
|
||||
if (pattern.test(code)) {
|
||||
offenders.push(`${path.relative(SERVER_ROOT, file)} matches ${pattern}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepStrictEqual(
|
||||
offenders,
|
||||
[],
|
||||
'the website process must never open a connection to a game server. If this is ' +
|
||||
'your sidecar client, allow that one file rather than removing the check — see ' +
|
||||
`the header of this file.\n ${offenders.join('\n ')}`,
|
||||
)
|
||||
})
|
||||
|
||||
test('every name on the allowlist is a file that exists and is shipped', () => {
|
||||
// A stale allowlist entry is a silent hole: the file it exempted was renamed,
|
||||
// the ban no longer covers the new name either (because the old one is still
|
||||
// listed and nothing matches it), and the check goes on passing. Holding the
|
||||
// list against the tree is what stops an exemption outliving its reason.
|
||||
const shipped = new Set(shippedFiles().map((f) => path.basename(f)))
|
||||
for (const name of MAY_OPEN_SOCKETS) {
|
||||
assert.ok(shipped.has(name), `${name} is allowed to open sockets but is not a shipped file`)
|
||||
}
|
||||
})
|
||||
|
||||
test('the check can actually fail — it is pointed at a real violation', () => {
|
||||
// A check that has never been shown to fail is a check nobody knows the state
|
||||
// of. This is the game-server dial the rule exists to stop.
|
||||
const violation = "const socket = new WebSocket('ws://10.0.0.5:28016/' + rconPassword)"
|
||||
assert.ok(
|
||||
NETWORKING.some((p) => p.test(stripComments(violation))),
|
||||
'the guard would not have caught a direct game-server dial',
|
||||
)
|
||||
})
|
||||
|
||||
test('prose describing the rule does not trip it', () => {
|
||||
const prose = [
|
||||
'// A game shipping RCON over WebSocket means a module COULD write',
|
||||
"// const s = new WebSocket(url); require('net')",
|
||||
'// in about ten lines. It must not.',
|
||||
'const x = 1',
|
||||
].join('\n')
|
||||
for (const pattern of NETWORKING) {
|
||||
assert.ok(!pattern.test(stripComments(prose)), `${pattern} fired on a comment`)
|
||||
}
|
||||
})
|
||||
116
server/test/schema.test.js
Normal file
116
server/test/schema.test.js
Normal file
@@ -0,0 +1,116 @@
|
||||
// ── The schema fragment, checked against §2.6's rules ─────────────────────
|
||||
//
|
||||
// Core validates the fragment at LOAD time and refuses to mount a module that
|
||||
// breaks a rule — with no tables created and no routes served. That is the right
|
||||
// behaviour and a slow way to find a typo, so the same rules are checked here.
|
||||
//
|
||||
// **This is also the suite that catches a half-finished rename.** Change the id
|
||||
// in `module.json` and forget a table name, and the prefix assertion below fails
|
||||
// immediately rather than at an operator's first boot.
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const manifest = require('../../module.json')
|
||||
|
||||
const read = (rel) => fs.readFileSync(path.resolve(__dirname, '..', '..', rel), 'utf8')
|
||||
|
||||
/**
|
||||
* Split a SQL file into statements the way core does.
|
||||
*
|
||||
* Core's own splitter is shared code (`utils/sqlStatements.js`) used by both the
|
||||
* loader and the schema replay — this is a small stand-in for a test, and it is
|
||||
* deliberately simple because the fragment it reads is deliberately simple. If
|
||||
* your schema grows a stored procedure or a string containing a semicolon, stop
|
||||
* trusting this and read the fragment a different way.
|
||||
*/
|
||||
function statements(sql) {
|
||||
return sql
|
||||
.split('\n')
|
||||
.filter((line) => !line.trim().startsWith('--'))
|
||||
.join('\n')
|
||||
.split(';')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
const schema = statements(read(manifest.schema))
|
||||
const purge = statements(read(manifest.purge))
|
||||
|
||||
// The allowlist core enforces. Note it is an ALLOWLIST and not a `DROP` denylist:
|
||||
// this file replays on every boot, so TRUNCATE or DELETE would empty a table on
|
||||
// every restart — which no denylist naming only DROP would have caught.
|
||||
const ALLOWED_VERBS = ['CREATE', 'ALTER', 'INSERT', 'UPDATE']
|
||||
|
||||
test('every statement starts with an allowed verb', () => {
|
||||
for (const statement of schema) {
|
||||
const verb = statement.split(/\s+/)[0].toUpperCase()
|
||||
assert.ok(ALLOWED_VERBS.includes(verb), `"${verb}" is not one of ${ALLOWED_VERBS.join(', ')}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('every table is prefixed with the module id', () => {
|
||||
for (const statement of schema) {
|
||||
const match = /^CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?\s+`?([A-Za-z0-9_]+)`?/i.exec(statement)
|
||||
if (!match) continue
|
||||
assert.ok(
|
||||
match[1].startsWith(`${manifest.id}_`),
|
||||
`table "${match[1]}" is not prefixed "${manifest.id}_" — core will refuse to load this module`,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('the fragment is idempotent — it replays on every boot', () => {
|
||||
for (const statement of schema) {
|
||||
if (/^CREATE\s+TABLE/i.test(statement)) {
|
||||
assert.match(statement, /IF\s+NOT\s+EXISTS/i, 'CREATE TABLE without IF NOT EXISTS')
|
||||
}
|
||||
if (/^ALTER\s+TABLE/i.test(statement) && /ADD\s+COLUMN/i.test(statement)) {
|
||||
assert.match(statement, /IF\s+NOT\s+EXISTS/i, 'ADD COLUMN without IF NOT EXISTS')
|
||||
}
|
||||
if (/^INSERT\s+INTO/i.test(statement)) {
|
||||
// A plain INSERT succeeds once and then fails the whole replay on the next
|
||||
// boot with a duplicate key — the classic "worked until I restarted it".
|
||||
assert.ok(
|
||||
/INSERT\s+IGNORE/i.test(statement) || /ON\s+DUPLICATE\s+KEY/i.test(statement),
|
||||
'INSERT must be IGNORE or carry ON DUPLICATE KEY — it runs again every boot',
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('purge drops every table the schema creates', () => {
|
||||
const created = schema
|
||||
.map((s) => /^CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?\s+`?([A-Za-z0-9_]+)`?/i.exec(s))
|
||||
.filter(Boolean)
|
||||
.map((m) => m[1])
|
||||
const dropped = purge
|
||||
.map((s) => /^DROP\s+TABLE(?:\s+IF\s+EXISTS)?\s+`?([A-Za-z0-9_]+)`?/i.exec(s))
|
||||
.filter(Boolean)
|
||||
.map((m) => m[1])
|
||||
|
||||
for (const table of created) {
|
||||
assert.ok(dropped.includes(table), `${table} is created but never dropped — purge would orphan it`)
|
||||
}
|
||||
for (const table of dropped) {
|
||||
assert.ok(created.includes(table), `${table} is dropped but never created`)
|
||||
}
|
||||
})
|
||||
|
||||
test('purge drops in the reverse of creation order', () => {
|
||||
// With one table this proves nothing; with a parent and its children it is the
|
||||
// difference between a clean teardown and a purge that fails halfway, leaving
|
||||
// exactly the orphaned data it exists to remove.
|
||||
const created = schema
|
||||
.map((s) => /^CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?\s+`?([A-Za-z0-9_]+)`?/i.exec(s))
|
||||
.filter(Boolean)
|
||||
.map((m) => m[1])
|
||||
const dropped = purge
|
||||
.map((s) => /^DROP\s+TABLE(?:\s+IF\s+EXISTS)?\s+`?([A-Za-z0-9_]+)`?/i.exec(s))
|
||||
.filter(Boolean)
|
||||
.map((m) => m[1])
|
||||
|
||||
assert.deepStrictEqual(dropped, [...created].reverse())
|
||||
})
|
||||
160
server/test/servers.test.js
Normal file
160
server/test/servers.test.js
Normal file
@@ -0,0 +1,160 @@
|
||||
// ── The servers model ─────────────────────────────────────────────────────
|
||||
//
|
||||
// No database and no express: the model takes rows and produces the shapes the
|
||||
// three tiers answer with, which is the whole reason the SQL lives in a separate
|
||||
// file from the logic.
|
||||
//
|
||||
// Two things here are worth more than the rest: **a token never leaves this
|
||||
// module**, and **a stale row cannot claim a server is up**.
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
|
||||
const { fakeCtx } = require('./_fakes')
|
||||
|
||||
function withCore(ctx = fakeCtx()) {
|
||||
require('../core')._reset()
|
||||
require('../core').init(ctx)
|
||||
return ctx
|
||||
}
|
||||
|
||||
const NOW = Date.parse('2026-09-15T12:00:00Z')
|
||||
|
||||
const serverRow = (over = {}) => ({
|
||||
id: 'main',
|
||||
name: 'Main · Vanilla',
|
||||
sidecarBaseUrl: 'http://10.0.0.5:8090',
|
||||
sidecarTokenEnc: 'enc:s3cret',
|
||||
protocol: 1,
|
||||
enabled: 1,
|
||||
sortOrder: 0,
|
||||
...over,
|
||||
})
|
||||
|
||||
const stateRow = (over = {}) => ({
|
||||
serverId: 'main',
|
||||
reachable: 1,
|
||||
online: 1,
|
||||
players: 42,
|
||||
maxPlayers: 100,
|
||||
hostname: 'Runic Gateway · Main',
|
||||
level: 'Procedural Map',
|
||||
seed: 1234,
|
||||
worldSize: 4000,
|
||||
bootId: 'boot-20260915T194502Z',
|
||||
protocol: 1,
|
||||
updatedAt: new Date(NOW - 10_000).toISOString(),
|
||||
...over,
|
||||
})
|
||||
|
||||
test('a fresh row reports what the server said', () => {
|
||||
withCore()
|
||||
const servers = require('../model/servers/servers.model')
|
||||
const shaped = servers.shapePublic(serverRow(), stateRow(), NOW)
|
||||
|
||||
assert.strictEqual(shaped.online, true)
|
||||
assert.strictEqual(shaped.players, 42)
|
||||
assert.strictEqual(shaped.stale, false)
|
||||
assert.strictEqual(shaped.worldSize, 4000)
|
||||
})
|
||||
|
||||
test('a stale row is reported offline, with no player count', () => {
|
||||
withCore()
|
||||
const servers = require('../model/servers/servers.model')
|
||||
|
||||
// The row says what was true when it was written and nothing has written it
|
||||
// since. Reporting its player count would put a number on a page that is
|
||||
// simply the last number anyone saw, with no way for a reader to tell.
|
||||
const old = stateRow({ updatedAt: new Date(NOW - servers.STALE_AFTER_MS - 1000).toISOString() })
|
||||
const shaped = servers.shapePublic(serverRow(), old, NOW)
|
||||
|
||||
assert.strictEqual(shaped.stale, true)
|
||||
assert.strictEqual(shaped.online, false)
|
||||
assert.strictEqual(shaped.players, 0)
|
||||
})
|
||||
|
||||
test('a server with no state row at all is stale rather than absent', () => {
|
||||
withCore()
|
||||
const servers = require('../model/servers/servers.model')
|
||||
|
||||
// A configured server nothing has polled yet. It belongs on the page — an
|
||||
// operator added it on purpose — and it must not claim to be online.
|
||||
const shaped = servers.shapePublic(serverRow(), undefined, NOW)
|
||||
|
||||
assert.strictEqual(shaped.id, 'main')
|
||||
assert.strictEqual(shaped.stale, true)
|
||||
assert.strictEqual(shaped.online, false)
|
||||
assert.strictEqual(shaped.updatedAt, null)
|
||||
})
|
||||
|
||||
test('the public shape carries nothing about the sidecar', () => {
|
||||
withCore()
|
||||
const servers = require('../model/servers/servers.model')
|
||||
const shaped = servers.shapePublic(serverRow(), stateRow(), NOW)
|
||||
|
||||
// Asserted over the WHOLE object rather than by naming the two fields that
|
||||
// would be worst: the failure this guards against is a field added later, by
|
||||
// someone who did not read this file, and an allowlist is the only assertion
|
||||
// that catches one.
|
||||
assert.deepStrictEqual(Object.keys(shaped).sort(), [
|
||||
'hostname', 'id', 'level', 'maxPlayers', 'name', 'online', 'players', 'seed', 'stale', 'updatedAt', 'worldSize',
|
||||
])
|
||||
})
|
||||
|
||||
test('the admin shape reports whether a token is stored, never the token', () => {
|
||||
withCore()
|
||||
const servers = require('../model/servers/servers.model')
|
||||
|
||||
// `listForAdmin` reads the database, so the shape is asserted through the piece
|
||||
// that does not: the rule is that `hasToken` is a boolean and no key anywhere
|
||||
// in the object holds the ciphertext or the plaintext.
|
||||
const row = serverRow()
|
||||
const shaped = {
|
||||
...servers.shapePublic(row, stateRow(), NOW),
|
||||
sidecarBaseUrl: row.sidecarBaseUrl,
|
||||
hasToken: Boolean(row.sidecarTokenEnc),
|
||||
}
|
||||
|
||||
assert.strictEqual(shaped.hasToken, true)
|
||||
const serialised = JSON.stringify(shaped)
|
||||
assert.ok(!serialised.includes('s3cret'), 'the plaintext token reached a response shape')
|
||||
assert.ok(!serialised.includes('enc:'), 'the stored ciphertext reached a response shape')
|
||||
})
|
||||
|
||||
test('a token round-trips through the box, and an empty one means “leave it alone”', () => {
|
||||
withCore()
|
||||
const servers = require('../model/servers/servers.model')
|
||||
|
||||
const enc = servers.encryptToken('s3cret')
|
||||
assert.notStrictEqual(enc, 's3cret')
|
||||
assert.strictEqual(servers.withToken(serverRow({ sidecarTokenEnc: enc })).token, 's3cret')
|
||||
|
||||
// All three spellings of "the operator did not type a new token". The admin
|
||||
// form can only ever show a blank field, so it posts one on every save that did
|
||||
// not intend to change the credential — and writing that through would erase
|
||||
// the token every time somebody renamed a server.
|
||||
assert.strictEqual(servers.encryptToken(''), null)
|
||||
assert.strictEqual(servers.encryptToken(null), null)
|
||||
assert.strictEqual(servers.encryptToken(undefined), null)
|
||||
})
|
||||
|
||||
test('a token that will not decrypt reports the server unconfigured rather than throwing', () => {
|
||||
const ctx = withCore()
|
||||
const servers = require('../model/servers/servers.model')
|
||||
|
||||
// The usual cause is a `SECRET_ENC_KEY` that changed. One server's unreadable
|
||||
// credential must not be able to fail the poll for the other five, and it must
|
||||
// not fail `onBoot` — which would make the whole module `startup_failed`.
|
||||
const shaped = servers.withToken(serverRow({ sidecarTokenEnc: 'not-encrypted-by-this-box' }))
|
||||
|
||||
assert.strictEqual(shaped.token, null)
|
||||
assert.strictEqual(shaped.baseUrl, 'http://10.0.0.5:8090')
|
||||
const errors = ctx.logs.flatMap((l) => l.log.error.calls)
|
||||
assert.strictEqual(errors.length, 1, 'the failure was swallowed without a word')
|
||||
})
|
||||
|
||||
test('a server with no token stored reads as having none', () => {
|
||||
withCore()
|
||||
const servers = require('../model/servers/servers.model')
|
||||
assert.strictEqual(servers.withToken(serverRow({ sidecarTokenEnc: null })).token, null)
|
||||
})
|
||||
814
swagger-fragment.json
Normal file
814
swagger-fragment.json
Normal file
@@ -0,0 +1,814 @@
|
||||
{
|
||||
"paths": {
|
||||
"/api/v1/admin/rust/servers": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Admin · Rust"
|
||||
],
|
||||
"summary": "Every configured Rust server",
|
||||
"description": "The operator’s server rows with their sidecar URLs, whether a token is stored, and whether each sidecar was reachable on the last poll. The token itself is never returned.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The configured servers",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RustAdminServerList"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/rust/servers/{id}": {
|
||||
"put": {
|
||||
"tags": [
|
||||
"Admin · Rust"
|
||||
],
|
||||
"summary": "Create or update a Rust server",
|
||||
"description": "Writes one server row. `sidecarToken` is write-only — send it to set or rotate the credential, and omit it or send an empty string to leave the stored one untouched. The id is the slug every URL under the module carries.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "Saved"
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid body"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
},
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"example": "any"
|
||||
},
|
||||
"sidecarBaseUrl": {
|
||||
"example": "any"
|
||||
},
|
||||
"sidecarToken": {
|
||||
"example": "any"
|
||||
},
|
||||
"protocol": {
|
||||
"example": "any"
|
||||
},
|
||||
"enabled": {
|
||||
"example": "any"
|
||||
},
|
||||
"sortOrder": {
|
||||
"example": "any"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Admin · Rust"
|
||||
],
|
||||
"summary": "Remove a Rust server",
|
||||
"description": "Deletes the server row and the observed state that hangs off it. It does not touch the sidecar or the game host — those are removed with the installer.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "Deleted"
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/rust/servers/{id}/test": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Admin · Rust"
|
||||
],
|
||||
"summary": "Probe a server’s sidecar",
|
||||
"description": "Calls the sidecar’s health endpoint with the stored credential and reports what came back — whether it answered, whether the bridge plugin is connected to it, and which protocol version it speaks. This is the one route that tells a wrong URL from a wrong token from a mismatched version.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "What the sidecar said",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RustSidecarProbe"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "No such server"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/player/rust/servers": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Player · Rust"
|
||||
],
|
||||
"summary": "The Rust servers, for a signed-in player",
|
||||
"description": "The same servers the public list carries, answered on the authenticated tier. It is the address a signed-in client calls, so that per-player detail can be added here without moving it. Requires a session.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The server list",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RustServerList"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/public/rust/servers": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Public · Rust"
|
||||
],
|
||||
"summary": "Every Rust server this site follows",
|
||||
"description": "The operator’s configured Rust servers and what each one last reported. Answers with `online: false` and `stale: true` rather than failing when a game server or its sidecar is unreachable — the site’s availability does not depend on the game’s.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The server list",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RustServerList"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
{
|
||||
"name": "Public · Rust",
|
||||
"description": "The Rust servers this site follows, as each one last reported itself"
|
||||
},
|
||||
{
|
||||
"name": "Player · Rust",
|
||||
"description": "The Rust surface for a signed-in player"
|
||||
},
|
||||
{
|
||||
"name": "Admin · Rust",
|
||||
"description": "Configuring the Rust servers and their sidecars"
|
||||
}
|
||||
],
|
||||
"components": {
|
||||
"schemas": {
|
||||
"RustServerList": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Every Rust server this site follows (GET /public/rust/servers)."
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"servers": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "array"
|
||||
},
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/RustServer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"RustServer": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "One Rust server, as it last reported itself."
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "main"
|
||||
}
|
||||
}
|
||||
},
|
||||
"name": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "Main · Vanilla"
|
||||
}
|
||||
}
|
||||
},
|
||||
"online": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "boolean"
|
||||
},
|
||||
"example": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"players": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"example": {
|
||||
"type": "number",
|
||||
"example": 42
|
||||
}
|
||||
}
|
||||
},
|
||||
"maxPlayers": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"example": {
|
||||
"type": "number",
|
||||
"example": 100
|
||||
}
|
||||
}
|
||||
},
|
||||
"hostname": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "Runic Gateway · Main"
|
||||
}
|
||||
}
|
||||
},
|
||||
"level": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "Procedural Map"
|
||||
}
|
||||
}
|
||||
},
|
||||
"worldSize": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"example": {
|
||||
"type": "number",
|
||||
"example": 4000
|
||||
}
|
||||
}
|
||||
},
|
||||
"seed": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"example": {
|
||||
"type": "number",
|
||||
"example": 1234
|
||||
}
|
||||
}
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"example": "date-time"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"stale": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "boolean"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Has nothing reported in longer than the freshness window? A stale row is reported offline."
|
||||
},
|
||||
"example": {
|
||||
"type": "boolean",
|
||||
"example": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"RustAdminServerList": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "The configured servers, with their sidecar settings (GET /admin/rust/servers)."
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"servers": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "array"
|
||||
},
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/RustAdminServer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"RustAdminServer": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "One configured server. The sidecar token is never included — `hasToken` reports only whether one is stored."
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "main"
|
||||
}
|
||||
}
|
||||
},
|
||||
"name": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "Main · Vanilla"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sidecarBaseUrl": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "http://10.0.0.5:8090"
|
||||
}
|
||||
}
|
||||
},
|
||||
"hasToken": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "boolean"
|
||||
},
|
||||
"example": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"protocol": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"example": {
|
||||
"type": "number",
|
||||
"example": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"enabled": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "boolean"
|
||||
},
|
||||
"example": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"sortOrder": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"example": {
|
||||
"type": "number",
|
||||
"example": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"reachable": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "boolean"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Did the sidecar answer on the last poll? Separate from `online`, which is about the game rather than the bridge."
|
||||
},
|
||||
"example": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"bootId": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "boot-20260915T194502Z"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sidecarProtocol": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"example": {
|
||||
"type": "number",
|
||||
"example": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"online": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "boolean"
|
||||
},
|
||||
"example": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"players": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"example": {
|
||||
"type": "number",
|
||||
"example": 42
|
||||
}
|
||||
}
|
||||
},
|
||||
"stale": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "boolean"
|
||||
},
|
||||
"example": {
|
||||
"type": "boolean",
|
||||
"example": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"RustSidecarProbe": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "What a sidecar said when probed (POST /admin/rust/servers/{id}/test)."
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ok": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "boolean"
|
||||
},
|
||||
"example": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "What happened, in one word — this is what tells a wrong URL from a wrong token from a mismatched protocol. One of `ok`, `no-token`, `unauthorized`, `protocol-mismatch`, `timeout`, `transport-error`, or `http-<code>`."
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "ok"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sidecar": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "The sidecar’s own health document, or the mismatch detail on a protocol disagreement."
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "ok"
|
||||
}
|
||||
}
|
||||
},
|
||||
"protocol": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"example": {
|
||||
"type": "number",
|
||||
"example": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"plugin_connected": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "boolean"
|
||||
},
|
||||
"example": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"database": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "ok"
|
||||
}
|
||||
}
|
||||
},
|
||||
"uptime": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "3h 2m"
|
||||
}
|
||||
}
|
||||
},
|
||||
"last_event": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"example": "date-time"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user